diff --git a/.DS_Store b/.DS_Store index c01e319..ad3c3e6 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/app/dist.zip b/app/dist.zip deleted file mode 100644 index a5b630e..0000000 Binary files a/app/dist.zip and /dev/null differ diff --git a/app/src/App.tsx b/app/src/App.tsx index f0446ba..a581c80 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -17,6 +17,8 @@ interface Player { revive: boolean; timeBombTurns: number; skipTurn: boolean; + // 放大镜透视的格子 {cellIndex: contentType} + revealedCells?: { [cellIndex: number]: string }; } interface GridCell { @@ -177,73 +179,38 @@ const App: React.FC = () => { } }; - // 再来一局: 获取新token并重新匹配 + // 再来一局: 通知小程序返回游戏入口页面 const refreshAndPlayAgain = async () => { + console.log('🔄 [再来一局] 通知小程序返回游戏入口...'); + addLog('system', '🔄 正在返回游戏入口...'); setIsRefreshing(true); - addLog('system', '🔄 正在获取新的游戏凭证...'); - try { - const backendUrl = 'https://game.1024tool.vip'; - // 从小程序环境获取用户token - let userToken = ''; - if ((window as any).uni && (window as any).uni.getStorageSync) { - userToken = (window as any).uni.getStorageSync('token') || ''; - } - - if (!userToken) { - addLog('system', '❌ 无法获取用户凭证,请返回小程序重新进入'); - setIsRefreshing(false); - // 回退到小程序 - if ((window as any).uni) { - (window as any).uni.navigateBack(); - } else if ((window as any).wx?.miniProgram) { - (window as any).wx.miniProgram.navigateBack(); - } - return; - } - - const resp = await fetch(`${backendUrl}/api/app/games/enter`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${userToken}` - }, - body: JSON.stringify({ game_code: 'minesweeper' }) - }); - - if (!resp.ok) { - const errData = await resp.json().catch(() => ({})); - addLog('system', `❌ 获取游戏凭证失败: ${errData.message || '游戏次数不足'}`); - setIsRefreshing(false); - return; - } - - const data = await resp.json(); - if (data.game_token) { - addLog('system', '✅ 获取新凭证成功!'); - - // 重置游戏状态 - setGameState(null); - setMatchId(null); - setIsMatching(false); - - // 重新初始化 Nakama 并开始匹配 - nakamaManager.initClient(data.nakama_server || 'wss://nakama.1024tool.vip', data.nakama_key || 'defaultkey'); - await nakamaManager.authenticateWithGameToken(data.game_token); - setIsConnected(true); - addLog('system', '✅ 重新连接成功'); - - // 自动开始匹配 - setTimeout(() => startMatchmaking(), 500); - } else { - addLog('system', '❌ 获取游戏凭证失败'); - } - } catch (err) { - console.error('Refresh token error:', err); - addLog('system', '❌ 网络错误,请稍后重试'); + // 方案1: 使用 postMessage 通知小程序 + if ((window as any).wx?.miniProgram) { + console.log(' -> 使用 wx.miniProgram.postMessage + navigateBack'); + (window as any).wx.miniProgram.postMessage({ data: { action: 'playAgain' } }); + setTimeout(() => { + (window as any).wx.miniProgram.navigateBack(); + }, 100); + return; } + // 方案2: 使用 uni 的 redirectTo 直接跳转到游戏入口页面 + if ((window as any).uni) { + console.log(' -> 使用 uni.navigateBack'); + // 先发送消息再返回 + (window as any).uni.postMessage({ data: { action: 'playAgain' } }); + setTimeout(() => { + (window as any).uni.navigateBack(); + }, 100); + return; + } + + // 方案3: 浏览器环境 - 刷新页面(需要新token) + console.log(' -> 浏览器环境, 刷新页面'); + addLog('system', '❌ 浏览器环境无法自动获取新token,请手动刷新页面'); setIsRefreshing(false); + alert('请手动刷新页面或从小程序重新进入游戏'); }; const GuideModal = () => ( @@ -449,8 +416,26 @@ const App: React.FC = () => { // Special event from server (character abilities, item effects) const event = data as unknown as { type: string; playerId: string; playerName: string; targetId?: string; targetName?: string; itemId?: string; value?: number; message: string }; const isMe = event.playerId === myUserIdRef.current; - const prefix = isMe ? '你' : (event.playerName?.substring(0, 8) || '对手'); - addLog('effect', `${prefix}: ${event.message}`); + const iAmTarget = event.targetId === myUserIdRef.current; + + // 翻译道具名称 + const itemNames: { [key: string]: string } = { + knife: '🔪飞刀', lightning: '⚡闪电', poison: '☠️毒药', + curse: '👻诅咒', bomb_timer: '💣定时炸弹' + }; + const itemDisplayName = event.itemId ? (itemNames[event.itemId] || event.itemId) : ''; + + // 显示日志 + if (isMe) { + addLog('effect', `你: ${event.message}`); + } else if (iAmTarget && event.type === 'damage') { + // 我被别人攻击了 - 特别提示 + const attackerName = event.playerName?.substring(0, 8) || '对手'; + addLog('effect', `⚠️ 你被 ${attackerName} 用 ${itemDisplayName} 攻击了!`); + } else { + const prefix = event.playerName?.substring(0, 8) || '对手'; + addLog('effect', `${prefix}: ${event.message}`); + } // Show floating damage/heal for affected players if (event.type === 'damage' || event.type === 'item') { @@ -459,7 +444,7 @@ const App: React.FC = () => { if (isGlobalDamage && event.value) { // Global damage - show on my screen spawnLabel(50, 50, `-${event.value}`, 'damage', undefined, myUserIdRef.current || undefined); - } else if (event.targetId === myUserIdRef.current && event.value) { + } else if (iAmTarget && event.value) { // I'm the target of damage spawnLabel(50, 50, `-${event.value}`, 'damage', undefined, myUserIdRef.current || undefined); } @@ -473,7 +458,7 @@ const App: React.FC = () => { } }; } - } catch (error) { + } catch { addLog('system', '连接服务器失败'); } }; @@ -536,16 +521,35 @@ const App: React.FC = () => {
🎮 需要 {matchPlayerCount} 名玩家
- {matchingTimer > 3 && ( + {matchingTimer > 3 && matchingTimer <= 30 && (
⚡ 匹配中...预计1-5秒完成
)} - {matchingTimer > 8 && ( + {matchingTimer > 30 && matchingTimer <= 60 && (
⏳ 人数不足,继续等待中...
)} + {matchingTimer > 60 && ( +
+ ⚠️ 等待超时,建议取消后重试 +
+ )} + + {/* 取消匹配按钮 */} + ) : ( @@ -614,10 +618,23 @@ const App: React.FC = () => {
{gameState.grid.map((cell, idx) => { const showContent = cell.revealed || debugMode; + // 检查当前玩家是否放大镜透视了这个格子 + const myRevealedContent = myPlayer?.revealedCells?.[idx]; + const hasMagnifierMark = !!myRevealedContent && !cell.revealed; + return ( -
handleCellClick(idx)} className={`aspect-square rounded-sm relative flex items-center justify-center text-sm lg:text-2xl border border-white/5 transition-all - ${showContent ? (cell.type === 'bomb' ? 'bg-rose-900/50' + (cell.revealed ? ' explosion' : '') : cell.type === 'item' ? `bg-blue-900/40${cell.revealed ? ` item-${cell.itemId || 'generic'}` : ''}` : 'bg-slate-800/50') : 'bg-emerald-900/20 hover:bg-emerald-800/40 cursor-pointer'} +
handleCellClick(idx)} className={`aspect-square rounded-sm relative flex items-center justify-center text-sm lg:text-2xl border transition-all + ${showContent ? (cell.type === 'bomb' ? 'bg-rose-900/50' + (cell.revealed ? ' explosion' : '') : cell.type === 'item' ? `bg-blue-900/40${cell.revealed ? ` item-${cell.itemId || 'generic'}` : ''}` : 'bg-slate-800/50') : hasMagnifierMark ? 'bg-purple-900/40 border-purple-500/50 border-dashed' : 'bg-emerald-900/20 hover:bg-emerald-800/40 cursor-pointer border-white/5'} ${debugMode && !cell.revealed ? 'opacity-60 border-dashed border-yellow-500/50' : ''}`}> + {/* 放大镜透视标记 */} + {hasMagnifierMark && ( +
+ + {myRevealedContent === 'bomb' ? '💣' : myRevealedContent === 'empty' ? '✅' : '🎁'} + + 🔍 +
+ )} {showContent && ( cell.type === 'bomb' ? '💣' : cell.type === 'item' ? getItemIcon(cell.itemId) : @@ -677,20 +694,6 @@ const App: React.FC = () => { > {isRefreshing ? '🔄 正在准备...' : '🚀 再来一局'} -
)} diff --git a/docker-compose.all.yml b/docker-compose.all.yml index b49958a..905cc9f 100644 --- a/docker-compose.all.yml +++ b/docker-compose.all.yml @@ -5,7 +5,7 @@ services: # 1. 业务后端 (Bindbox Game Backend) # ---------------------------------------------------- bindbox-game: - image: zfc931912343/bindbox-game:v1.12 + image: zfc931912343/bindbox-game:v1.15 container_name: bindbox-game restart: always ports: @@ -54,7 +54,7 @@ services: # 3. 游戏服务器 (Nakama) # ---------------------------------------------------- nakama: - image: zfc931912343/bindbox-saolei:v1.5 + image: zfc931912343/bindbox-saolei:v1.6 container_name: nakama-server environment: # 直接使用服务名访问后端 diff --git a/server/Dockerfile b/server/Dockerfile index 0f85963..b886774 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /backend COPY go.mod . COPY go.sum . -COPY main.go . +COPY . . RUN go build --trimpath --mod=mod --buildmode=plugin -o ./backend.so diff --git a/server/backend.so b/server/backend.so index a1860e9..4fc94bf 100644 Binary files a/server/backend.so and b/server/backend.so differ diff --git a/server/characters/characters_test.go b/server/characters/characters_test.go new file mode 100644 index 0000000..ceff75f --- /dev/null +++ b/server/characters/characters_test.go @@ -0,0 +1,129 @@ +package characters + +import ( + "testing" + "wuziqi-server/core" +) + +func TestGetInitialHP(t *testing.T) { + // Setup custom config + hpConfig := map[string]int{ + "custom_char": 10, + } + manager := NewCharacterManager(hpConfig) + + // Test Case 1: Configured Override + hp := manager.GetInitialHP("custom_char", 4) + if hp != 10 { + t.Errorf("Expected configured HP 10, got %d", hp) + } + + // Test Case 2: Config Override Default (if key exists) + // If we map "dog" -> 6 + manager.HPConfig["dog"] = 6 + hp = manager.GetInitialHP("dog", 4) + if hp != 6 { + t.Errorf("Expected configured dog HP 6, got %d", hp) + } + + // Test Case 3: Global Default (hardcoded default arg) + // Passing a new manager for cleanliness + manager2 := NewCharacterManager(nil) + hp = manager2.GetInitialHP("unknown", 5) + if hp != 5 { + t.Errorf("Expected default arg HP 5, got %d", hp) + } + + // Test Case 4: Character Data Default + // "cat" is 3 in manager.go data + hp = manager2.GetInitialHP("cat", 0) + if hp != 3 { + t.Errorf("Expected cat data HP 3, got %d", hp) + } + + // Test Case 5: Final Fallback + hp = manager2.GetInitialHP("unknown_and_no_default", 0) + if hp != 4 { + t.Errorf("Expected final fallback 4, got %d", hp) + } +} + +func TestOnDamageTaken(t *testing.T) { + manager := NewCharacterManager(nil) + p := &core.Player{} + + // Test Cat: Always takes 1 dmg + p.Character = "cat" + dmg := manager.OnDamageTaken(p, 100, false) + if dmg != 1 { + t.Errorf("Cat should take 1 dmg, got %d", dmg) + } + + // Test Other: Takes raw dmg + p.Character = "dog" + dmg = manager.OnDamageTaken(p, 5, false) + if dmg != 5 { + t.Errorf("Dog should take 5 dmg, got %d", dmg) + } +} + +func TestShouldResistPoison(t *testing.T) { + manager := NewCharacterManager(nil) + + if !manager.ShouldResistPoison("sloth") { + t.Error("Sloth should resist poison") + } + if manager.ShouldResistPoison("dog") { + t.Error("Dog should not resist poison") + } +} + +func TestTryTriggerChickenAbility(t *testing.T) { + manager := NewCharacterManager(nil) + p := &core.Player{Character: "chicken", ChickenItemCount: 0} + + // This is probabilistic, so hard to deterministic test without mocking rand. + // But we can verify logic constraints. + + // If count >= 2, should never trigger + p.ChickenItemCount = 2 + item, triggered := manager.TryTriggerChickenAbility(p) + if triggered { + t.Error("Chicken should not trigger if count >= 2") + } + if item != "" { + t.Error("Should return empty item") + } + + // Not Chicken + p.Character = "dog" + p.ChickenItemCount = 0 + _, triggered = manager.TryTriggerChickenAbility(p) + if triggered { + t.Error("Non-chicken should not trigger") + } +} + +func TestTryTriggerHippoResist(t *testing.T) { + manager := NewCharacterManager(nil) + p := &core.Player{Character: "hippo", HippoDeathImmune: false} + + // Probabilistic again. + // Test constraint: if already immune, won't trigger again? + // Actually code says: `if !target.HippoDeathImmune` -> set true -> return true. + // So if it returns true, immune flag must be set. + + p.HippoDeathImmune = true + triggered := manager.TryTriggerHippoResist(p) + if triggered { + t.Error("Should not trigger if already immune used") + } + + // Not Hippo + p.Character = "dog" + p.HippoDeathImmune = false + triggered = manager.TryTriggerHippoResist(p) + if triggered { + t.Error("Non-hippo should not trigger") + } +} diff --git a/server/characters/manager.go b/server/characters/manager.go new file mode 100644 index 0000000..9c82b0b --- /dev/null +++ b/server/characters/manager.go @@ -0,0 +1,111 @@ +package characters + +import ( + "math/rand" + "wuziqi-server/core" +) + +var CharacterData = map[string]struct { + MaxHP int + Avatar string + Desc string +}{ + "elephant": {MaxHP: 5, Avatar: "🐘", Desc: "High HP, can't use some items"}, + "cat": {MaxHP: 3, Avatar: "🐱", Desc: "Max dmg taken is 1"}, + "dog": {MaxHP: 4, Avatar: "🐶", Desc: "Periodic magnifier"}, + "monkey": {MaxHP: 4, Avatar: "🐒", Desc: "Get banana (heal) chance"}, + "chicken": {MaxHP: 4, Avatar: "🐔", Desc: "Get item on dmg chance"}, + "sloth": {MaxHP: 4, Avatar: "🦥", Desc: "Immune poison, bomb dmg reduced"}, + "hippo": {MaxHP: 4, Avatar: "🦛", Desc: "Cant pick items, resist death chance"}, + "tiger": {MaxHP: 4, Avatar: "🐯", Desc: "Stronger knife"}, +} + +type CharacterManager struct { + HPConfig map[string]int +} + +func NewCharacterManager(hpConfig map[string]int) *CharacterManager { + return &CharacterManager{ + HPConfig: hpConfig, + } +} + +func (m *CharacterManager) GetInitialHP(charType string, defaultHP int) int { + // ★ 优先级: + // 1. 配置化的角色HP(从管理后台设置) + if configHP, ok := m.HPConfig[charType]; ok && configHP > 0 { + return configHP + } + // 2. 硬编码的角色默认值(CharacterData) + if data, ok := CharacterData[charType]; ok && data.MaxHP > 0 { + return data.MaxHP + } + // 3. 全局默认值 + if defaultHP > 0 { + return defaultHP + } + return 4 // Fallback +} + +func (m *CharacterManager) GetAvatar(charType string) string { + if data, ok := CharacterData[charType]; ok { + return data.Avatar + } + return "❓" +} + +// OnDamageTaken 根据角色特质(例如猫)修改伤害 +func (m *CharacterManager) OnDamageTaken(target *core.Player, amount int, isItemEffect bool) int { + // 猫咪天赋: 所有伤害强制为1点(诅咒加成也无效) + if target.Character == "cat" { + return 1 + } + // 树懒减少受到的炸弹伤害(不是道具?原来的代码说"bomb dmg reduced") + // 原始代码逻辑: + // 在 handleMove(炸弹)中:如果是树懒 dmg = 1(定时炸弹) + // 在 resolveItem(定时炸弹)中:爆炸 -> applyDamage + // 等等,原始 applyDamage 没有检查树懒对普通伤害的减免。 + // 它只在 handleMove 中检查了炸弹格子:`dmg := 2`(没检查?等下) + // 原始 `handleMove`: + // if cell.Type == "bomb" { dmg := 2 } 这里没有检查树懒? + // 等等,让我重读原始 `handleMove`。 + // 第 1106 行:`dmg := 2`。没有树懒检查。 + // 第 1426 行(定时炸弹):`if nextPlayer.Character == "sloth" { dmg = 1 }` + // 所以树懒只抵抗定时炸弹?还是我查错地方了? + // 让我们检查原来的 applyDamage。 + // `applyDamage` 处理护盾、猫、诅咒。 + // 树懒的抵抗是在 `handleMove` 逻辑中针对定时炸弹爆炸处理的。 + + // 所以这里的 OnDamageTaken 应该只处理猫。 + return amount +} + +// ShouldResistPoison 检查角色是否抵抗毒药 +func (m *CharacterManager) ShouldResistPoison(charType string) bool { + return charType == "sloth" +} + +// ChickenAbility 处理鸡的"受伤获得道具"技能 +// 如果触发则返回 itemID,否则返回空字符串 +func (m *CharacterManager) TryTriggerChickenAbility(target *core.Player) (string, bool) { + if target.Character == "chicken" && target.ChickenItemCount < 2 { + if rand.Float32() < 0.08 { + target.ChickenItemCount++ + items := []string{"skip", "shield", "magnifier"} + return items[rand.Intn(len(items))], true + } + } + return "", false +} + +// HippoDeathResist 检查河马是否幸免于难 +func (m *CharacterManager) TryTriggerHippoResist(target *core.Player) bool { + if target.Character == "hippo" && !target.HippoDeathImmune { + // 55% chance to survive death (once per game) + if rand.Float32() < 0.55 { + target.HippoDeathImmune = true + return true + } + } + return false +} diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 0000000..b9c15ce --- /dev/null +++ b/server/config/config.go @@ -0,0 +1,171 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/heroiclabs/nakama-common/runtime" +) + +var ( + BackendBaseURL = "http://host.docker.internal:9991/api/internal" // 默认值 + InternalAPIKey = "bindbox-internal-secret-2024" // 必须与后端匹配 +) + +var httpClient = &http.Client{ + Timeout: 5 * time.Second, +} + +// MakeInternalRequest 发送带认证的内部API请求的辅助函数 +func MakeInternalRequest(method, url string, body []byte) (*http.Response, error) { + req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Internal-Key", InternalAPIKey) + return httpClient.Do(req) +} + +type MinesweeperConfig struct { + GridSize int `json:"grid_size"` + BombCount int `json:"bomb_count"` + ItemMin int `json:"item_min"` + ItemMax int `json:"item_max"` + HPInit int `json:"hp_init"` + MatchPlayerCount int `json:"match_player_count"` + EnabledItems map[string]bool `json:"enabled_items"` + ItemWeights map[string]int `json:"item_weights"` + CharacterHP map[string]int `json:"character_hp"` // 每个角色的独立HP配置 + TurnDuration int `json:"turn_duration"` // 每回合的限时(秒) +} + +func GetMinesweeperConfig(logger runtime.Logger) *MinesweeperConfig { + url := BackendBaseURL + "/game/minesweeper/config" + logger.Info("Fetching minesweeper config from: %s", url) + + resp, err := MakeInternalRequest("GET", url, nil) + if err != nil { + logger.Error("Network error fetching config (check BackendBaseURL): %v", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + logger.Error("Backend config API returned status: %d (URL: %s)", resp.StatusCode, url) + return nil + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + logger.Error("Failed to read config response body: %v", err) + return nil + } + + var config MinesweeperConfig + if err := json.Unmarshal(body, &config); err != nil { + logger.Error("Failed to parse minesweeper config: %v. Raw body: %s", err, string(body)) + return nil + } + + return &config +} + +type VerifyTicketResponse struct { + Valid bool `json:"valid"` + UserID string `json:"user_id"` + RemainingTimes int `json:"remaining_times"` +} + +type SettleGameResponse struct { + Success bool `json:"success"` + Reward string `json:"reward"` +} + +// GameTokenInfo 包含从后端验证通过的Token信息 +type GameTokenInfo struct { + Valid bool `json:"valid"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Avatar string `json:"avatar"` + GameType string `json:"game_type"` + Ticket string `json:"ticket"` + Error string `json:"error"` +} + +// ValidateGameToken 向后端验证游戏Token并返回用户信息 +func ValidateGameToken(logger runtime.Logger, gameToken string) *GameTokenInfo { + logger.Info("Validating game token with backend") + + reqBody, _ := json.Marshal(map[string]string{ + "game_token": gameToken, + }) + + resp, err := MakeInternalRequest("POST", BackendBaseURL+"/game/validate-token", reqBody) + if err != nil { + logger.Error("Failed to call backend validate-token API: %v", err) + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + logger.Error("Backend validate-token returned non-200 status: %d", resp.StatusCode) + return nil + } + + body, _ := ioutil.ReadAll(resp.Body) + var result GameTokenInfo + if err := json.Unmarshal(body, &result); err != nil { + logger.Error("Failed to parse validate-token response: %v", err) + return nil + } + + if !result.Valid { + logger.Warn("Game token invalid: %s", result.Error) + return nil + } + + return &result +} + +// GetBackendBaseURL 允许 main.go 从环境变量更新基础URL +func SetBackendBaseURL(url string) { + BackendBaseURL = url +} + +func GetBackendBaseURL() string { + return BackendBaseURL +} + +// SettleGameWithBackend 使用真实用户ID向后端结算游戏 +func SettleGameWithBackend(logger runtime.Logger, realUserID int64, ticket, matchID string, win bool, score int) { + logger.Info("Settling game with backend for realUserID %d (Win: %v)", realUserID, win) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "user_id": fmt.Sprintf("%d", realUserID), // 后端需要字符串类型 + "ticket": ticket, + "match_id": matchID, + "win": win, + "score": score, + }) + + // Async call to not block game loop too much, or use goroutine + go func() { + resp, err := MakeInternalRequest("POST", BackendBaseURL+"/game/settle", reqBody) + if err != nil { + logger.Error("Failed to call backend settle API: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + logger.Error("Backend settle returned non-200: %d", resp.StatusCode) + } else { + logger.Info("Game settled successfully with backend") + } + }() +} diff --git a/server/core/types.go b/server/core/types.go new file mode 100644 index 0000000..48b4d88 --- /dev/null +++ b/server/core/types.go @@ -0,0 +1,254 @@ +package core + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/heroiclabs/nakama-common/runtime" +) + +// --- 常量与枚举 --- + +const ( + OpCodeGameStart = 1 + OpCodeUpdateState = 2 + OpCodeMove = 3 + OpCodeGameEvent = 5 // 特殊游戏事件(道具使用、角色技能) + OpCodeGameOver = 6 + OpCodeGetState = 100 // 请求当前状态的新操作码 + + MaxPlayers = 2 // 默认值,将被配置覆盖 +) + +// --- 结构体 --- + +type GridCell struct { + Type string `json:"type"` // "empty" | "bomb" | "item" + ItemID string `json:"itemId,omitempty"` + Revealed bool `json:"revealed"` + NeighborBombs int `json:"neighborBombs"` // 相邻炸弹数量 +} + +type Player struct { + UserID string `json:"userId"` // Nakama 用户ID + RealUserID int64 `json:"realUserId"` // 后端用户ID(用于结算) + SessionID string `json:"sessionId"` + Username string `json:"username"` + Avatar string `json:"avatar"` + HP int `json:"hp"` + MaxHP int `json:"maxHp"` + Status []string `json:"status"` // 视觉状态标签 + Character string `json:"character"` + Ticket string `json:"ticket"` // 存储用于加入游戏的入场券 + + // Status Flags + Shield bool `json:"shield"` + SkipTurn bool `json:"skipTurn"` + Poisoned bool `json:"poisoned"` + PoisonSteps int `json:"poisonSteps"` // 中毒后经过的步数 + Revive bool `json:"revive"` + Curse bool `json:"curse"` + TimeBombTurns int `json:"timeBombTurns"` // 定时炸弹倒计时(0 = 无炸弹) + + // Character ability usage counters (for limits) + MonkeyBananaCount int `json:"monkeyBananaCount"` // 每局最多2次 + ChickenItemCount int `json:"chickenItemCount"` // 每局最多2次 + HippoDeathImmune bool `json:"hippoDeathImmune"` // 使用一次后为真 + ChestCount int `json:"chestCount"` // 收集的宝箱(游戏结束时奖励) + DogStepCount int `json:"dogStepCount"` // 狗狗自身移动步数(用于触发嗅觉天赋) + + // Magnifier reveals (cell index -> cell type) + RevealedCells map[int]string `json:"revealedCells"` + + // Disconnect tracking (for timeout logic) + DisconnectTime int64 `json:"disconnectTime,omitempty"` // 玩家断线时的Unix时间戳(0 = 已连接) +} + +type GameState struct { + Players map[string]*Player `json:"players"` + Grid []*GridCell `json:"grid"` + GridSize int `json:"gridSize"` // 正方形网格的边长 + TurnOrder []string `json:"turnOrder"` + CurrentTurnIndex int `json:"currentTurnIndex"` + Round int `json:"round"` + GlobalTurnCount int `json:"globalTurnCount"` // 总回合数(用于狗狗技能) + WinnerID string `json:"winnerId"` + GameStarted bool `json:"gameStarted"` + LastMoveTimestamp int64 `json:"lastMoveTimestamp"` // Unix时间戳(秒) + ServerTime int64 `json:"serverTime"` // 当前服务器时间 + TurnDuration int `json:"turnDuration"` // 每回合限时(秒) +} + +type MoveMessage struct { + Index int `json:"index"` +} + +type GetStateMessage struct { + Action string `json:"action"` +} + +// GameEvent 代表在客户端日志中显示的特殊事件 +type GameEvent struct { + Type string `json:"type"` // "ability", "item", "damage", "heal", "status" + PlayerID string `json:"playerId"` + PlayerName string `json:"playerName"` + TargetID string `json:"targetId,omitempty"` + TargetName string `json:"targetName,omitempty"` + ItemID string `json:"itemId,omitempty"` + Value int `json:"value,omitempty"` + Message string `json:"message"` + // 用于放大镜/狗狗技能:仅向拥有者揭示格子信息 + CellIndex int `json:"cellIndex,omitempty"` // 揭示格子的索引 + CellType string `json:"cellType,omitempty"` // 揭示格子的类型(炸弹/空白/道具ID) +} + +// --- Helper Methods --- + +// Sanitize 返回用于客户端广播的经过脱敏的游戏状态副本 +func (g *GameState) Sanitize() *GameState { + clone := *g + clone.ServerTime = time.Now().Unix() + + // 1. 脱敏网格(隐藏未揭示的格子) + sanitizedGrid := make([]*GridCell, len(g.Grid)) + for i, cell := range g.Grid { + c := *cell // 格子结构体的浅拷贝 + if !c.Revealed { + // 掩盖隐藏格子 + c.Type = "empty" + c.ItemID = "" + c.NeighborBombs = 0 + } + sanitizedGrid[i] = &c + } + clone.Grid = sanitizedGrid + + // 2. 深拷贝玩家映射并清除revealedCells以确保安全 + sanitizedPlayers := make(map[string]*Player, len(g.Players)) + for uid, p := range g.Players { + playerCopy := *p + // 在广播副本中清除revealedCells + playerCopy.RevealedCells = make(map[int]string) + sanitizedPlayers[uid] = &playerCopy + } + clone.Players = sanitizedPlayers + + return &clone +} + +// SanitizeForUser 返回特定用户的经过脱敏的游戏状态副本 +func (g *GameState) SanitizeForUser(userID string) *GameState { + clone := *g + clone.ServerTime = time.Now().Unix() + + // 1. 脱敏网格 + sanitizedGrid := make([]*GridCell, len(g.Grid)) + for i, cell := range g.Grid { + c := *cell + if !c.Revealed { + c.Type = "empty" + c.ItemID = "" + c.NeighborBombs = 0 + } + sanitizedGrid[i] = &c + } + clone.Grid = sanitizedGrid + + // 2. 深拷贝玩家映射 + sanitizedPlayers := make(map[string]*Player, len(g.Players)) + for uid, p := range g.Players { + playerCopy := *p + if uid == userID { + // 保留该用户的revealedCells + playerCopy.RevealedCells = make(map[int]string, len(p.RevealedCells)) + for k, v := range p.RevealedCells { + playerCopy.RevealedCells[k] = v + } + } else { + // 清除其他玩家的revealedCells + playerCopy.RevealedCells = make(map[int]string) + } + sanitizedPlayers[uid] = &playerCopy + } + clone.Players = sanitizedPlayers + + return &clone +} + +func BroadcastEvent(dispatcher runtime.MatchDispatcher, event GameEvent) { + data, _ := json.Marshal(event) + dispatcher.BroadcastMessage(OpCodeGameEvent, data, nil, nil, true) +} + +// CreatePublicGameState 创建观众可见的游戏状态 +func CreatePublicGameState(state *GameState) *GameState { + publicState := &GameState{ + Players: make(map[string]*Player), + Grid: state.Grid, // 网格是公开的 + GridSize: state.GridSize, + TurnOrder: state.TurnOrder, + CurrentTurnIndex: state.CurrentTurnIndex, + Round: state.Round, + GlobalTurnCount: state.GlobalTurnCount, + WinnerID: state.WinnerID, + GameStarted: state.GameStarted, + } + + for uid, player := range state.Players { + publicPlayer := *player + publicPlayer.RevealedCells = nil // 观众看不到私有信息 + publicState.Players[uid] = &publicPlayer + } + + return publicState +} + +// 获取邻居索引的辅助函数 +func GetNeighborIndices(index int, gridSize int) []int { + neighbors := []int{} + row := index / gridSize + col := index % gridSize + + for r := row - 1; r <= row+1; r++ { + for c := col - 1; c <= col+1; c++ { + if (r == row && c == col) || r < 0 || r >= gridSize || c < 0 || c >= gridSize { + continue + } + neighbors = append(neighbors, r*gridSize+c) + } + } + return neighbors +} + +// FormatCoordinates 将索引转换为人类可读的坐标字符串 (R, C) +func (g *GameState) FormatCoordinates(index int) string { + if g.GridSize <= 0 { + return "未知" + } + row := (index / g.GridSize) + 1 + col := (index % g.GridSize) + 1 + return fmt.Sprintf("(%d, %d)", row, col) +} + +func TranslateCellType(cellType string) string { + mapTypes := map[string]string{ + "empty": "安全区", + "bomb": "炸弹", + "medkit": "医疗包", + "bomb_timer": "定时炸弹", + "poison": "毒药瓶", + "shield": "护盾", + "skip": "好人卡", + "magnifier": "放大镜", + "knife": "飞刀", + "revive": "复活甲", + "lightning": "闪电", + "chest": "宝箱", + "curse": "诅咒", + } + if val, ok := mapTypes[cellType]; ok { + return val + } + return "未知道具" +} diff --git a/server/game_test_runner.go b/server/game_test_runner.go new file mode 100644 index 0000000..45925ee --- /dev/null +++ b/server/game_test_runner.go @@ -0,0 +1,460 @@ +package main + +import ( + "fmt" + "math/rand" + "time" +) + +// ======================== +// 测试辅助结构 +// ======================== + +type TestPlayer struct { + UserID string + Username string + Character string + HP, MaxHP int + Shield bool + Poisoned bool + PoisonSteps int + Curse bool + Revive bool + TimeBombTurns int + SkipTurn bool + RevealedCells map[int]string + MonkeyBananaCount int + ChickenItemCount int + HippoDeathImmune bool +} + +type TestGrid struct { + Type string + ItemID string + Revealed bool +} + +func NewTestPlayer(id, character string) *TestPlayer { + maxHP := 4 + if character == "elephant" { + maxHP = 5 + } else if character == "cat" { + maxHP = 3 + } + return &TestPlayer{ + UserID: id, + Username: "Player_" + id, + Character: character, + HP: maxHP, + MaxHP: maxHP, + RevealedCells: make(map[int]string), + } +} + +// ======================== +// 核心逻辑函数 +// ======================== + +func testApplyDamage(target *TestPlayer, amount int) int { + if target.HP <= 0 { + return 0 + } + + if target.Shield { + target.Shield = false + return 0 + } + + if target.Character == "cat" { + amount = 1 + target.Curse = false + } else if target.Curse { + amount *= 2 + target.Curse = false + } + + target.HP -= amount + + if target.HP <= 0 { + if target.Revive { + target.Revive = false + target.HP = 1 + } else if target.Character == "hippo" && !target.HippoDeathImmune { + if rand.Float32() < 0.55 { + target.HP = 1 + target.HippoDeathImmune = true + } + } + } + + if target.HP < 0 { + target.HP = 0 + } + + return amount +} + +func testHealPlayer(p *TestPlayer, amount int) { + if p.HP < p.MaxHP { + p.HP += amount + if p.HP > p.MaxHP { + p.HP = p.MaxHP + } + } +} + +// ======================== +// 测试结果 +// ======================== + +var passed, failed int + +func assert(name string, condition bool, msg string) { + if !condition { + fmt.Printf(" ❌ %s: %s\n", name, msg) + failed++ + } else { + passed++ + } +} + +// ======================== +// 道具测试 +// ======================== + +func TestMedkit() { + fmt.Println("\n📦 测试: 医疗包") + p := NewTestPlayer("1", "default") + p.HP = 2 + p.Poisoned = true + p.PoisonSteps = 3 + + p.Poisoned = false + p.PoisonSteps = 0 + testHealPlayer(p, 1) + + assert("回复1血", p.HP == 3, fmt.Sprintf("期望HP=3, 实际HP=%d", p.HP)) + assert("解除中毒", !p.Poisoned, "中毒状态未解除") +} + +func TestBombTimer() { + fmt.Println("\n📦 测试: 定时炸弹") + + // 普通玩家 + p := NewTestPlayer("1", "default") + p.TimeBombTurns = 3 + for i := 0; i < 3; i++ { + if p.TimeBombTurns > 0 { + p.TimeBombTurns-- + if p.TimeBombTurns == 0 { + testApplyDamage(p, 2) + } + } + } + assert("3回合后扣2血", p.HP == 2, fmt.Sprintf("期望HP=2, 实际HP=%d", p.HP)) + + // 懒懒 + p2 := NewTestPlayer("1", "sloth") + p2.TimeBombTurns = 3 + for i := 0; i < 3; i++ { + if p2.TimeBombTurns > 0 { + p2.TimeBombTurns-- + if p2.TimeBombTurns == 0 { + testApplyDamage(p2, 1) // 懒懒减伤 + } + } + } + assert("懒懒伤害降为1", p2.HP == 3, fmt.Sprintf("期望HP=3, 实际HP=%d", p2.HP)) +} + +func TestPoison() { + fmt.Println("\n📦 测试: 毒药瓶") + p := NewTestPlayer("1", "default") + p.Poisoned = true + p.PoisonSteps = 0 + + for i := 0; i < 4; i++ { + if p.Poisoned { + p.PoisonSteps++ + if p.PoisonSteps%2 == 0 { + testApplyDamage(p, 1) + } + } + } + assert("每2回合扣1血(4回合扣2)", p.HP == 2, fmt.Sprintf("期望HP=2, 实际HP=%d", p.HP)) +} + +func TestShield() { + fmt.Println("\n📦 测试: 护盾") + p := NewTestPlayer("1", "default") + p.Shield = true + testApplyDamage(p, 2) + assert("免疫1次伤害", p.HP == 4, fmt.Sprintf("期望HP=4, 实际HP=%d", p.HP)) + assert("使用后消失", !p.Shield, "护盾应消失") +} + +func TestSkip() { + fmt.Println("\n📦 测试: 好人卡") + p := NewTestPlayer("1", "default") + p.SkipTurn = true + p.Shield = true + assert("跳过回合", p.SkipTurn, "SkipTurn应为true") + assert("附带护盾", p.Shield, "应带护盾效果") +} + +func TestKnife() { + fmt.Println("\n📦 测试: 飞刀") + target := NewTestPlayer("2", "default") + testApplyDamage(target, 1) + assert("随机敌人1伤害", target.HP == 3, fmt.Sprintf("期望HP=3, 实际HP=%d", target.HP)) + + // 老虎飞刀 + t1 := NewTestPlayer("2", "default") + t2 := NewTestPlayer("3", "default") + testApplyDamage(t1, 2) + testApplyDamage(t2, 2) + assert("老虎全体2伤害", t1.HP == 2 && t2.HP == 2, fmt.Sprintf("期望2,2 实际%d,%d", t1.HP, t2.HP)) +} + +func TestRevive() { + fmt.Println("\n📦 测试: 复活甲") + p := NewTestPlayer("1", "default") + p.Revive = true + p.HP = 1 + testApplyDamage(p, 5) + assert("免疫一次死亡保留1血", p.HP == 1, fmt.Sprintf("期望HP=1, 实际HP=%d", p.HP)) + assert("使用后消失", !p.Revive, "复活甲应消失") +} + +func TestLightning() { + fmt.Println("\n📦 测试: 闪电") + players := []*TestPlayer{NewTestPlayer("1", "default"), NewTestPlayer("2", "default")} + for _, p := range players { + testApplyDamage(p, 1) + } + assert("全体扣1血", players[0].HP == 3 && players[1].HP == 3, "所有玩家应扣1血") +} + +func TestCurse() { + fmt.Println("\n📦 测试: 诅咒") + p := NewTestPlayer("1", "default") + p.Curse = true + testApplyDamage(p, 1) + assert("伤害翻倍", p.HP == 2, fmt.Sprintf("期望HP=2(4-1*2), 实际HP=%d", p.HP)) + assert("触发后消失", !p.Curse, "诅咒应消失") +} + +// ======================== +// 角色测试 +// ======================== + +func TestElephant() { + fmt.Println("\n🐘 测试: 大象") + p := NewTestPlayer("1", "elephant") + assert("HP=5", p.MaxHP == 5, fmt.Sprintf("期望MaxHP=5, 实际=%d", p.MaxHP)) + + // 模拟禁用道具逻辑 + bannedItems := map[string]bool{"medkit": true, "skip": true, "revive": true} + for item := range bannedItems { + canUse := !(p.Character == "elephant" && bannedItems[item]) + assert(fmt.Sprintf("禁用%s", item), !canUse, "应禁用该道具") + } +} + +func TestCat() { + fmt.Println("\n🐱 测试: 猫咪") + p := NewTestPlayer("1", "cat") + assert("HP=3", p.MaxHP == 3, fmt.Sprintf("期望MaxHP=3, 实际=%d", p.MaxHP)) + + testApplyDamage(p, 5) + assert("伤害强制为1", p.HP == 2, fmt.Sprintf("期望HP=2, 实际HP=%d", p.HP)) + + p2 := NewTestPlayer("1", "cat") + p2.Curse = true + testApplyDamage(p2, 1) + assert("诅咒无效", p2.HP == 2, fmt.Sprintf("期望HP=2, 实际HP=%d", p2.HP)) +} + +func TestDog() { + fmt.Println("\n🐶 测试: 狗狗") + p := NewTestPlayer("1", "dog") + grid := []TestGrid{{Type: "bomb"}, {Type: "item", ItemID: "medkit"}} + + idx := 0 + cellType := grid[idx].Type + p.RevealedCells[idx] = cellType + + assert("透视存入RevealedCells", p.RevealedCells[idx] == "bomb", "应存入bomb") +} + +func TestMonkey() { + fmt.Println("\n🐒 测试: 猴子(吉吉国王)") + p := NewTestPlayer("1", "monkey") + p.HP = 2 + + for i := 0; i < 3; i++ { + if p.MonkeyBananaCount < 2 { + testHealPlayer(p, 1) + p.MonkeyBananaCount++ + } + } + assert("香蕉最多2次", p.MonkeyBananaCount == 2, fmt.Sprintf("触发%d次", p.MonkeyBananaCount)) + assert("回复2血", p.HP == 4, fmt.Sprintf("期望HP=4, 实际HP=%d", p.HP)) +} + +func TestChicken() { + fmt.Println("\n🐔 测试: 坤坤") + p := NewTestPlayer("1", "chicken") + + for i := 0; i < 3; i++ { + if p.ChickenItemCount < 2 { + p.ChickenItemCount++ + } + } + assert("道具触发最多2次", p.ChickenItemCount == 2, fmt.Sprintf("触发%d次", p.ChickenItemCount)) +} + +func TestSloth() { + fmt.Println("\n🦥 测试: 懒懒") + p := NewTestPlayer("1", "sloth") + + canPoison := p.Character != "sloth" + assert("免疫毒药", !canPoison, "应免疫毒药") + + dmg := 2 + if p.Character == "sloth" { + dmg = 1 + } + assert("炸弹伤害降为1", dmg == 1, fmt.Sprintf("伤害=%d", dmg)) +} + +func TestHippo() { + fmt.Println("\n🦛 测试: 河马") + p := NewTestPlayer("1", "hippo") + + canPickup := p.Character != "hippo" + assert("无法拾取道具", !canPickup, "应无法拾取") + + // 统计性测试 + survived := 0 + trials := 10000 + for i := 0; i < trials; i++ { + testP := NewTestPlayer("1", "hippo") + testP.HP = 1 + testApplyDamage(testP, 2) + if testP.HP > 0 { + survived++ + } + } + rate := float64(survived) / float64(trials) + assert("55%免死概率", rate > 0.50 && rate < 0.60, fmt.Sprintf("实际=%.1f%%", rate*100)) +} + +func TestTiger() { + fmt.Println("\n🐯 测试: 老虎") + attacker := NewTestPlayer("1", "tiger") + + dmg := 1 + isAOE := false + if attacker.Character == "tiger" { + dmg = 2 + isAOE = true + } + assert("飞刀变2伤害", dmg == 2, fmt.Sprintf("伤害=%d", dmg)) + assert("变为全体攻击", isAOE, "应为AOE") +} + +func TestMagnifier() { + fmt.Println("\n🔍 测试: 放大镜") + p := NewTestPlayer("1", "default") + grid := []TestGrid{ + {Type: "empty"}, {Type: "bomb"}, {Type: "item", ItemID: "shield"}, + } + + // 模拟放大镜逻辑 + idx := 2 + cellType := grid[idx].Type + if grid[idx].Type == "item" { + cellType = grid[idx].ItemID + } + p.RevealedCells[idx] = cellType + + assert("存入道具ID", p.RevealedCells[idx] == "shield", fmt.Sprintf("应为shield,实际=%s", p.RevealedCells[idx])) +} + +// ======================== +// 综合场景测试 +// ======================== + +func TestComplexScenarios() { + fmt.Println("\n🎮 测试: 综合场景") + + // 护盾+诅咒 + p1 := NewTestPlayer("1", "default") + p1.Shield = true + p1.Curse = true + testApplyDamage(p1, 2) + assert("护盾优先于诅咒", p1.HP == 4 && p1.Curse, "护盾应先生效,诅咒保留") + + // 复活甲+毒死 + p2 := NewTestPlayer("1", "default") + p2.Revive = true + p2.Poisoned = true + p2.PoisonSteps = 1 + testApplyDamage(p2, 5) + p2.PoisonSteps++ + if p2.PoisonSteps%2 == 0 { + testApplyDamage(p2, 1) + } + assert("复活后被毒死", p2.HP == 0, fmt.Sprintf("期望HP=0, 实际=%d", p2.HP)) +} + +// ======================== +// 主函数 +// ======================== + +func main() { + rand.Seed(time.Now().UnixNano()) + + fmt.Println("╔═══════════════════════════════════════════╗") + fmt.Println("║ 🎮 动物扫雷 - 道具&角色逻辑测试 ║") + fmt.Println("╚═══════════════════════════════════════════╝") + + // 道具测试 + fmt.Println("\n━━━ 道具测试 ━━━") + TestMedkit() + TestBombTimer() + TestPoison() + TestShield() + TestSkip() + TestMagnifier() + TestKnife() + TestRevive() + TestLightning() + TestCurse() + + // 角色测试 + fmt.Println("\n━━━ 角色测试 ━━━") + TestElephant() + TestCat() + TestDog() + TestMonkey() + TestChicken() + TestSloth() + TestHippo() + TestTiger() + + // 综合测试 + fmt.Println("\n━━━ 综合场景 ━━━") + TestComplexScenarios() + + // 结果 + fmt.Println("\n╔═══════════════════════════════════════════╗") + total := passed + failed + if failed == 0 { + fmt.Printf("║ ✅ 全部通过! %d/%d 测试用例 ║\n", passed, total) + } else { + fmt.Printf("║ ⚠️ %d 通过, %d 失败 (共%d) ║\n", passed, failed, total) + } + fmt.Println("╚═══════════════════════════════════════════╝") +} diff --git a/server/handlers/match.go b/server/handlers/match.go new file mode 100644 index 0000000..149509b --- /dev/null +++ b/server/handlers/match.go @@ -0,0 +1,570 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "math/rand" + "time" + + "wuziqi-server/characters" + "wuziqi-server/config" + "wuziqi-server/core" + "wuziqi-server/items" + "wuziqi-server/logic" + + "github.com/heroiclabs/nakama-common/runtime" +) + +type MatchLabel struct { + Open bool `json:"open"` + Started bool `json:"started"` + PlayerCount int `json:"player_count"` + MaxPlayers int `json:"max_players"` + Label string `json:"label"` +} + +type MatchHandler struct{} + +func (m *MatchHandler) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) { + logger.Info("MatchInit (Refactored) called") + + // 1. 加载配置 + apiConfig := config.GetMinesweeperConfig(logger) + + // 默认值 + gridSizeSide := 10 + bombCount := 30 + itemMin := 5 + itemMax := 10 + hpInit := 4 + matchPlayerCount := core.MaxPlayers + enabledItems := make(map[string]bool) + itemWeights := make(map[string]int) + charHPConfig := make(map[string]int) + turnDuration := 15 + + if apiConfig != nil { + if apiConfig.GridSize > 0 { + gridSizeSide = apiConfig.GridSize + } + if apiConfig.BombCount > 0 { + bombCount = apiConfig.BombCount + } + if apiConfig.ItemMin >= 0 { + itemMin = apiConfig.ItemMin + } + if apiConfig.ItemMax >= itemMin { + itemMax = apiConfig.ItemMax + } + if apiConfig.HPInit > 0 { + hpInit = apiConfig.HPInit + } + if apiConfig.MatchPlayerCount >= 2 { + matchPlayerCount = apiConfig.MatchPlayerCount + } + enabledItems = apiConfig.EnabledItems + itemWeights = apiConfig.ItemWeights + charHPConfig = apiConfig.CharacterHP + if apiConfig.TurnDuration > 0 { + turnDuration = apiConfig.TurnDuration + } + } + + // 强制最小值检查 (防止配置为0导致死循环) + if turnDuration < 5 { + logger.Warn("TurnDuration too small (%d), resetting to 15", turnDuration) + turnDuration = 15 + } + + // 2. 初始化管理器 + charMgr := characters.NewCharacterManager(charHPConfig) + itemMgr := items.NewItemManager() + + // 3. 生成网格 + // 如果池需要回退,我们需要将所有道具类型传递给 GenerateGrid + // 道具已在 ItemManager 中注册,但我们需要ID字符串列表 + // 我们在这里硬编码列表或从 items 包中公开它。 + // 目前,硬编码以匹配以前的逻辑。 + allItemTypes := []string{ + "medkit", "bomb_timer", "poison", "shield", "skip", + "magnifier", "knife", "revive", "lightning", "chest", "curse", + } + + grid := logic.GenerateGrid(gridSizeSide, bombCount, itemMin, itemMax, enabledItems, itemWeights, allItemTypes) + + // 4. 创建初始状态 + gameState := &core.GameState{ + Players: make(map[string]*core.Player), + Grid: grid, + GridSize: gridSizeSide, + TurnOrder: make([]string, 0), + CurrentTurnIndex: 0, + Round: 1, + WinnerID: "", + GameStarted: false, + TurnDuration: turnDuration, + } + logger.Info("MatchInit: Configured TurnDuration: %d (API Config: %v)", turnDuration, apiConfig != nil) + + // 5. 创建比赛状态 + // 我们需要一个持有引擎的简化 MatchState 吗? + // 或者应该在 MatchLoop 中创建 logic.GameEngine? + // Nakama 在调用之间传递 'state' interface{}。 + // 我们应该存储 GameState + 引擎依赖项。 + + // 初始化角色池 + charTypes := []string{"elephant", "cat", "dog", "monkey", "chicken", "sloth", "hippo", "tiger"} + shuffledChars := make([]string, len(charTypes)) + copy(shuffledChars, charTypes) + rand.Shuffle(len(shuffledChars), func(i, j int) { + shuffledChars[i], shuffledChars[j] = shuffledChars[j], shuffledChars[i] + }) + + matchState := &MatchState{ + State: gameState, + HPInit: hpInit, + MatchPlayerCount: matchPlayerCount, + ValidatedPlayers: make(map[string]*config.GameTokenInfo), + DisconnectedPlayers: make(map[string]*core.Player), + CharacterPool: shuffledChars, + CharacterIndex: 0, + Spectators: make(map[string]bool), + Presences: make(map[string]runtime.Presence), + CharManager: charMgr, + ItemManager: itemMgr, + } + + tickRate := 10 + label := "Animal Minesweeper" + + // 初始化 Label + initialLabel := MatchLabel{ + Open: true, + Started: false, + PlayerCount: 0, + MaxPlayers: matchPlayerCount, + Label: label, + } + labelBytes, _ := json.Marshal(initialLabel) + + return matchState, tickRate, string(labelBytes) +} + +type MatchState struct { + State *core.GameState + HPInit int + MatchPlayerCount int + ValidatedPlayers map[string]*config.GameTokenInfo + DisconnectedPlayers map[string]*core.Player + CharacterPool []string + CharacterIndex int + Spectators map[string]bool + Presences map[string]runtime.Presence + + // 用于构建引擎的管理器 + CharManager *characters.CharacterManager + ItemManager *items.ItemManager +} + +// ... MatchJoinAttempt, MatchJoin, MatchLoop ... + +func (m *MatchHandler) MatchJoinAttempt(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string) (interface{}, bool, string) { + ms := state.(*MatchState) + + // Token 验证逻辑 + gameToken, ok := metadata["game_token"] + if !ok || gameToken == "" { + logger.Warn("MatchJoinAttempt: No game_token provided for user %s (SID: %s). Metadata: %v", presence.GetUserId(), presence.GetSessionId(), metadata) + return ms, false, "Game token required" + } + + logger.Info("MatchJoinAttempt: Validating token for user %s", presence.GetUserId()) + tokenInfo := config.ValidateGameToken(logger, gameToken) + if tokenInfo == nil { + logger.Error("MatchJoinAttempt: Invalid game token for user %s", presence.GetUserId()) + return ms, false, "Invalid game token" + } + + ms.ValidatedPlayers[presence.GetUserId()] = tokenInfo + + if ms.State.GameStarted { + if _, ok := ms.DisconnectedPlayers[presence.GetUserId()]; ok { + return ms, true, "" // 重连 + } + ms.Spectators[presence.GetUserId()] = true + return ms, true, "" // 观众 + } + + if len(ms.State.Players) >= ms.MatchPlayerCount { + return ms, false, "Match full" + } + + return ms, true, "" +} + +func (m *MatchHandler) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} { + ms := state.(*MatchState) + + // 仅在需要时构建引擎?还是只使用它的组件? + // 如果需要,我们可以在本地构建它以使用其逻辑,但 Join 逻辑主要是设置。 + + matchID, _ := ctx.Value(runtime.RUNTIME_CTX_MATCH_ID).(string) + + for _, presence := range presences { + userID := presence.GetUserId() + ms.Presences[userID] = presence // 统一追踪 Presence,确保消息路由正确 + + // 记录活跃对局到存储,用于全设备重连查找 + nk.StorageWrite(ctx, []*runtime.StorageWrite{ + { + Collection: "game_data", + Key: "active_match", + UserID: userID, + Value: fmt.Sprintf(`{"match_id":"%s"}`, matchID), + PermissionRead: 2, // Owner Read + PermissionWrite: 0, // Server Only + }, + }) + + // 观众 + if ms.Spectators[userID] { + publicState := core.CreatePublicGameState(ms.State) + data, _ := json.Marshal(publicState) + dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{presence}, nil, true) + continue + } + + // 重连 + if p, ok := ms.DisconnectedPlayers[userID]; ok { + p.SessionID = presence.GetSessionId() + ms.State.Players[userID] = p + delete(ms.DisconnectedPlayers, userID) + + sanitized := ms.State.SanitizeForUser(userID) + data, err := json.Marshal(sanitized) + if err != nil { + continue + } + dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{presence}, nil, true) + continue + } + + // 新玩家 + if _, exists := ms.State.Players[userID]; exists { + continue + } + + tokenInfo := ms.ValidatedPlayers[userID] + if tokenInfo == nil { + continue + } + + // 分配角色 + char := "dog" // 回退 + if ms.CharacterIndex < len(ms.CharacterPool) { + char = ms.CharacterPool[ms.CharacterIndex] + ms.CharacterIndex++ + } + + maxHP := ms.CharManager.GetInitialHP(char, ms.HPInit) + avatar := ms.CharManager.GetAvatar(char) + username := tokenInfo.Username + if username == "" { + username = presence.GetUsername() + } + + player := &core.Player{ + UserID: userID, + RealUserID: tokenInfo.UserID, + SessionID: presence.GetSessionId(), + Username: username, + Avatar: avatar, + HP: maxHP, + MaxHP: maxHP, + Character: char, + Ticket: tokenInfo.Ticket, + Status: []string{}, + RevealedCells: make(map[int]string), + } + + ms.State.Players[userID] = player + ms.State.TurnOrder = append(ms.State.TurnOrder, userID) + ms.Presences[userID] = presence // 追踪 presence + } + + // 更新 Label + ms.updateLabel(dispatcher) + + // 开始游戏检查 + if len(ms.State.Players) >= ms.MatchPlayerCount && !ms.State.GameStarted { + ms.State.GameStarted = true + ms.State.LastMoveTimestamp = time.Now().Unix() + + // 打乱回合顺序 + rand.Shuffle(len(ms.State.TurnOrder), func(i, j int) { + ms.State.TurnOrder[i], ms.State.TurnOrder[j] = ms.State.TurnOrder[j], ms.State.TurnOrder[i] + }) + + // 记录并消耗入场券 + // ... 入场券消耗逻辑(由于是处理器保持简单)... + for _, p := range ms.State.Players { + go consumeTicket(p.RealUserID, p.Ticket, logger) + } + + // 广播开始 + broadcastUpdate(dispatcher, ms.State, core.OpCodeGameStart) + ms.updateLabel(dispatcher) + } else { + // 更新大厅 + broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState) + } + + return ms +} + +func (m *MatchHandler) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} { + ms := state.(*MatchState) + + // 为此循环 tick 创建引擎实例 + engine := logic.NewGameEngine(logger, dispatcher, ms.CharManager, ms.ItemManager, ms.Presences, ms.DisconnectedPlayers) + + for _, message := range messages { + userID := message.GetUserId() + opCode := message.GetOpCode() + + logger.Debug("MatchLoop: Received msg from UserID=%s, OpCode=%d, DataLen=%d", userID, opCode, len(message.GetData())) + + if ms.Spectators[userID] { + continue + } + + switch opCode { + case core.OpCodeMove: + if !ms.State.GameStarted { + logger.Warn("Move ignored: game not started") + continue + } + var move core.MoveMessage + if err := json.Unmarshal(message.GetData(), &move); err != nil { + logger.Error("Failed to unmarshal move: %v", err) + continue + } + + // 委托给引擎 + engine.HandleMove(ms.State, userID, move.Index) + + case core.OpCodeGetState: + sanitized := ms.State.SanitizeForUser(message.GetUserId()) + data, _ := json.Marshal(sanitized) + targetPresence := ms.Presences[message.GetUserId()] + if targetPresence == nil { + // Presence 可能还未更新(重连时序问题),记录警告 + logger.Warn("OpCodeGetState: Presence not found for user %s, possible timing issue", message.GetUserId()) + continue + } + dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{targetPresence}, nil, true) + } + } + + // 超时检查 + if ms.State.GameStarted && ms.State.WinnerID == "" { + now := time.Now().Unix() + // Debug logging for timeout logic + if now-ms.State.LastMoveTimestamp >= int64(ms.State.TurnDuration) { + logger.Info("MatchLoop: Turn Timeout Triggered. Now: %d, Last: %d, Diff: %d, Duration: %d", + now, ms.State.LastMoveTimestamp, now-ms.State.LastMoveTimestamp, ms.State.TurnDuration) + + currentUID := ms.State.TurnOrder[ms.State.CurrentTurnIndex] + + if _, disconnected := ms.DisconnectedPlayers[currentUID]; disconnected { + // 跳过回合 + engine.BroadcastEvent(core.GameEvent{Type: "status", Message: "⏰ 玩家断线,跳过回合"}) + engine.AdvanceTurn(ms.State) + } else { + // 扣除HP并跳过 + player := ms.State.Players[currentUID] + if player != nil { + engine.ApplyDamage(ms.State, player, 1, false) // 1点HP惩罚,非道具效果 + engine.AdvanceTurn(ms.State) + } + } + ms.State.LastMoveTimestamp = now + + // 检查游戏结束 + if engine.CheckGameOver(ms.State) { + return ms + } + + // 广播更新 + broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState) + } + } + + // 检查游戏是否应该结束并销毁房间 + if !ms.State.GameStarted && ms.State.WinnerID != "" { + logger.Info("Match %s is over (Winner: %s), destroying room", ms.State.WinnerID, ms.State.WinnerID) + // 游戏结束,清理所有参与者的对局存储 + for uid := range ms.State.Players { + nk.StorageDelete(ctx, []*runtime.StorageDelete{ + { + Collection: "game_data", + Key: "active_match", + UserID: uid, + }, + }) + } + for uid := range ms.DisconnectedPlayers { + nk.StorageDelete(ctx, []*runtime.StorageDelete{ + { + Collection: "game_data", + Key: "active_match", + UserID: uid, + }, + }) + } + return nil + } + + return ms +} + +func (m *MatchHandler) MatchLeave(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} { + ms := state.(*MatchState) + + for _, presence := range presences { + userID := presence.GetUserId() + if ms.Spectators[userID] { + delete(ms.Spectators, userID) + delete(ms.Presences, userID) + continue + } + + if ms.State.GameStarted { // 将玩家标记为断开连接 + if p, ok := ms.State.Players[userID]; ok { + ms.DisconnectedPlayers[userID] = p + p.DisconnectTime = time.Now().Unix() + delete(ms.State.Players, userID) // 从活跃玩家中移除 + broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState) + } + } else { + delete(ms.State.Players, userID) + // 退出大厅时,清理对局存储 + nk.StorageDelete(ctx, []*runtime.StorageDelete{ + { + Collection: "game_data", + Key: "active_match", + UserID: userID, + }, + }) + // 从回合顺序中移除...(如果我们支持离开大厅则需要逻辑) + // 找到并移除 userID + for i, id := range ms.State.TurnOrder { + if id == userID { + ms.State.TurnOrder = append(ms.State.TurnOrder[:i], ms.State.TurnOrder[i+1:]...) + break + } + } + } + delete(ms.Presences, userID) // 移除 presence + } + + ms.updateLabel(dispatcher) + return ms +} + +func (ms *MatchState) updateLabel(dispatcher runtime.MatchDispatcher) { + label := MatchLabel{ + Open: !ms.State.GameStarted && len(ms.State.Players) < ms.MatchPlayerCount, + Started: ms.State.GameStarted, + PlayerCount: len(ms.State.Players), + MaxPlayers: ms.MatchPlayerCount, + Label: "Animal Minesweeper", + } + labelBytes, _ := json.Marshal(label) + _ = dispatcher.MatchLabelUpdate(string(labelBytes)) +} + +func (m *MatchHandler) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} { + return state +} + +func (m *MatchHandler) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) { + return state, data +} + +// 辅助函数 + +func broadcastUpdate(dispatcher runtime.MatchDispatcher, state *core.GameState, opCode int64) { + // 原始逻辑:向每个玩家发送已脱敏的状态 + // 我们需要遍历已连接的 presence 吗? + // 或者只是广播已脱敏的通用状态? + // 原始 main.go 循环: + // for _, presence := range presences ... SanitizeForUser ... + // 由于我们在全局函数中无法轻松获取 presence,我们要依赖玩家映射的键吗? + // 但是 Dispatcher 需要 presence 作为目标。 + // BroadcastMessage 使用 nil presence 会发送给所有人。 + // 但是我们需要针对每个用户的脱敏来处理隐藏格子。 + // Sanitize() 方法(无参数)清除所有已揭示的格子。这对公开广播是安全的。 + // 并且它更新所有人。 + // 但是正确的扫雷游戏需要用户看到他们自己揭示的格子。 + // 所以我们现在应该广播安全的通用版本,除非我们追踪 presence。 + + // 简化:广播通用的已脱敏状态(如果不是公开的,每个人都看到揭示的格子是隐藏的?) + // 等等,Sanitize() 清除了 Player 中的 RevealedCells 映射。 + // SanitizeForUser(uid) 保留该用户的格子。 + + // 如果我们使用 dispatcher.BroadcastMessage(..., nil, ...) 它会发送给所有人。 + // 没有 Presence 列表,我们无法在这里实现每用户的视图。 + // MatchState 通常不存储 presence 列表,Nakama 会管理它。 + // 如果我们需要完美的实现,可能需要重新考虑这一点。 + // 然而,原始 main.go 的 broadcastStateToAllPlayers 需要传递 presence。 + // 在 MatchLoop 中,我们没有在消息中传递所有已连接用户的 presence 列表。 + + // 原始代码 MatchJoin 使用 dispatcher.BroadcastMessage(..., nil) 广播,这会向所有人发送一条消息。 + // 而且 Sanitize() 清除了所有私有信息。 + // 逻辑 handleMove 也广播了 Sanitize()。 + // 所以原始代码实际上并没有发送私有信息? + // 让我们重读 main.go。 + + // 第 319 行:Sanitize() 清除所有 revealedCells。 + // 第 413 行:broadcastStateToAllPlayers 循环 presence... + // 但是 MatchLoop 调用 handleMove(第 1158 行)调用了 dispatcher.BroadcastMessage(..., state.Sanitize()...)! + // 所以在原始代码中,RevealedCells 从未通过广播更新显示给客户端? + // 放大镜事件发送 CellIndex 和 CellType。客户端使用该事件在本地更新地图? + // 是的:broadcastEvent 发送 CellIndex 和 CellType。 + // 所以如果事件携带私有信息,状态更新本身不需要携带它。 + + // 所以 Sanitize() 就足够了。 + + data, _ := json.Marshal(state.Sanitize()) + dispatcher.BroadcastMessage(opCode, data, nil, nil, true) +} + +func consumeTicket(realUserID int64, ticket string, logger runtime.Logger) { + if realUserID <= 0 { + return + } + + reqBody, _ := json.Marshal(map[string]string{ + "user_id": fmt.Sprintf("%d", realUserID), + "game_code": "minesweeper", + "ticket": ticket, + }) + + url := config.BackendBaseURL + "/game/consume-ticket" + + resp, err := config.MakeInternalRequest("POST", url, reqBody) + if err != nil { + logger.Error("Failed to call backend consume-ticket API for user %d: %v", realUserID, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + logger.Error("Backend consume-ticket returned status %d for user %d", resp.StatusCode, realUserID) + } else { + logger.Info("Ticket consumed successfully for user %d", realUserID) + } +} diff --git a/server/handlers/rpc.go b/server/handlers/rpc.go new file mode 100644 index 0000000..5700dc3 --- /dev/null +++ b/server/handlers/rpc.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "github.com/heroiclabs/nakama-common/runtime" +) + +func RpcListMatches(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { + limit := 100 + authoritative := true + label := "" // 我们想列出所有 animal_minesweeper + minSize := 0 + maxSize := 8 + query := "*" // 默认查询 + + matches, err := nk.MatchList(ctx, limit, authoritative, label, &minSize, &maxSize, query) + if err != nil { + logger.Error("Failed to list matches: %v", err) + return "", err + } + + result := make([]map[string]interface{}, 0) + for _, m := range matches { + var labelObj MatchLabel + if err := json.Unmarshal([]byte(m.GetLabel().Value), &labelObj); err != nil { + // 如果不是 minesweeper 房间,跳过 + continue + } + + result = append(result, map[string]interface{}{ + "match_id": m.GetMatchId(), + "player_count": labelObj.PlayerCount, + "max_players": labelObj.MaxPlayers, + "started": labelObj.Started, + "open": labelObj.Open, + }) + } + + response, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(response), nil +} + +func RpcFindMyMatch(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { + userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string) + if !ok { + return "", runtime.NewError("user not authenticated", 16) + } + + readObjects, err := nk.StorageRead(ctx, []*runtime.StorageRead{ + { + Collection: "game_data", + Key: "active_match", + UserID: userID, + }, + }) + + if err != nil { + logger.Error("Failed to read storage: %v", err) + return "", err + } + + if len(readObjects) == 0 { + return "{}", nil + } + + return readObjects[0].Value, nil +} + +// RpcGetOnlineCount 返回当前在线玩家数量(基于心跳) +func RpcGetOnlineCount(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) { + userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string) + if !ok || userID == "" { + return "", runtime.NewError("user not authenticated", 16) + } + + // 1. 更新当前用户的心跳时间戳 + now := time.Now().Unix() + heartbeatData, _ := json.Marshal(map[string]int64{"ts": now}) + + _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{ + { + Collection: "game_lobby", + Key: "heartbeat", + UserID: userID, + Value: string(heartbeatData), + PermissionRead: 0, // 不可读 + PermissionWrite: 0, // 仅服务器可写 + }, + }) + if err != nil { + logger.Warn("Failed to write heartbeat: %v", err) + } + + // 2. 读取所有用户的心跳记录 + cursor := "" + onlineCount := 0 + expireThreshold := now - 60 // 60秒内有心跳的算在线 + + for { + // StorageList(ctx, callerID, collection, userID, limit, cursor) + objects, nextCursor, err := nk.StorageList(ctx, "", "game_lobby", "", 100, cursor) + if err != nil { + logger.Error("Failed to list heartbeats: %v", err) + break + } + + for _, obj := range objects { + var data map[string]int64 + if err := json.Unmarshal([]byte(obj.Value), &data); err != nil { + continue + } + if ts, ok := data["ts"]; ok && ts >= expireThreshold { + onlineCount++ + } + } + + if nextCursor == "" { + break + } + cursor = nextCursor + } + + // 3. 获取比赛中的玩家数 + matches, _ := nk.MatchList(ctx, 100, true, "", nil, nil, "*") + inGameCount := 0 + for _, m := range matches { + inGameCount += int(m.GetSize()) + } + + result := map[string]interface{}{ + "online_count": onlineCount, + "match_count": len(matches), + "in_game_count": inGameCount, + } + + response, _ := json.Marshal(result) + return string(response), nil +} diff --git a/server/items/effects.go b/server/items/effects.go new file mode 100644 index 0000000..1be9cfb --- /dev/null +++ b/server/items/effects.go @@ -0,0 +1,226 @@ +package items + +import ( + "fmt" + "math/rand" + "wuziqi-server/core" +) + +// --- Medkit --- +type MedkitStrategy struct{} + +func (s *MedkitStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + if user.Character == "elephant" { + ctx.Logger.Info("Elephant refused medkit") + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "medkit", + Message: "🐘 大象无法使用该道具!", + }) + return false + } + user.Poisoned = false + user.PoisonSteps = 0 + ctx.Logic.HealPlayer(user, 1) + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "medkit", Value: 1, + Message: "💊 使用医疗包,回复1血并解除中毒!", + }) + return true +} + +// --- BombTimer --- +type BombTimerStrategy struct{} + +func (s *BombTimerStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + user.TimeBombTurns = 3 + ctx.Logger.Info("Player %s has a time bomb! Explodes in 3 turns", user.UserID) + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "bomb_timer", Value: 3, + Message: "⏰ 定时炸弹启动,3回合后爆炸!", + }) + return true +} + +// --- Poison --- +type PoisonStrategy struct{} + +func (s *PoisonStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + target := ctx.Logic.GetRandomAliveTarget(state, user.UserID) + if target != nil { + if target.Character == "sloth" { + ctx.Logger.Info("Sloth resisted poison") + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, TargetID: target.UserID, TargetName: target.Username, ItemID: "poison", + Message: "🦥 树懒免疫了毒药!", + }) + } else { + target.Poisoned = true + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, TargetID: target.UserID, TargetName: target.Username, ItemID: "poison", + Message: fmt.Sprintf("☠️ %s 中毒了!", target.Username), + }) + } + } + return true +} + +// --- Shield --- +type ShieldStrategy struct{} + +func (s *ShieldStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + user.Shield = true + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "shield", + Message: "🛡️ 获得护盾,可抵挡一次伤害!", + }) + return true +} + +// --- Skip --- +type SkipStrategy struct{} + +func (s *SkipStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + if user.Character == "elephant" { + ctx.Logger.Info("Elephant refused skip") + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "skip", + Message: "🐘 大象无法使用该道具!", + }) + return false + } + user.SkipTurn = true + user.Shield = true + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "skip", + Message: "⏭️ 好人卡:跳过回合并获得护盾!", + }) + return true +} + +// --- Magnifier --- +type MagnifierStrategy struct{} + +func (s *MagnifierStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + for i := 0; i < 100; i++ { + idx := rand.Intn(len(state.Grid)) + if !state.Grid[idx].Revealed { + cellType := state.Grid[idx].Type + if state.Grid[idx].Type == "item" { + cellType = state.Grid[idx].ItemID + } + user.RevealedCells[idx] = cellType + coords := state.FormatCoordinates(idx) + contentDesc := core.TranslateCellType(cellType) + msg := fmt.Sprintf("🔍 放大镜:透视了 %s,内容是 [%s]!", coords, contentDesc) + + ctx.Logger.Info("Magnifier: Player %s can now see cell %d (%s) at %s", user.UserID, idx, cellType, coords) + ctx.Logic.SendPrivateEvent(user.UserID, core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "magnifier", + Message: msg, + CellIndex: idx, CellType: cellType, + }) + break + } + } + return true +} + +// --- Knife --- +type KnifeStrategy struct{} + +func (s *KnifeStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + dmg := 1 + isAOE := false + // 检查场上是否有存活的老虎(任何玩家是老虎都会触发增强) + for _, p := range state.Players { + if p.Character == "tiger" && p.HP > 0 { + dmg = 2 + isAOE = true + break + } + } + + if isAOE { + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "knife", Value: dmg, + Message: fmt.Sprintf("🐯🔪 老虎在场!飞刀变为全体攻击,对所有敌人造成%d点伤害!", dmg), + }) + for _, p := range state.Players { + if p.UserID != user.UserID && p.HP > 0 { + ctx.Logic.ApplyDamage(state, p, dmg, true) + } + } + } else { + target := ctx.Logic.GetRandomAliveTarget(state, user.UserID) + if target != nil { + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, TargetID: target.UserID, TargetName: target.Username, ItemID: "knife", Value: dmg, + Message: fmt.Sprintf("🔪 飞刀命中 %s,造成%d点伤害!", target.Username, dmg), + }) + ctx.Logic.ApplyDamage(state, target, dmg, true) + } + } + return true +} + +// --- Revive --- +type ReviveStrategy struct{} + +func (s *ReviveStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + if user.Character == "elephant" { + ctx.Logger.Info("Elephant refused revive") + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "revive", + Message: "🐘 大象无法使用该道具!", + }) + return false + } + user.Revive = true + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "revive", + Message: "💖 获得复活甲,可免疫一次死亡!", + }) + return true +} + +// --- Lightning --- +type LightningStrategy struct{} + +func (s *LightningStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "lightning", Value: 1, + Message: "⚡ 闪电对所有玩家造成1点伤害!", + }) + for _, p := range state.Players { + ctx.Logic.ApplyDamage(state, p, 1, true) + } + return true +} + +// --- Chest --- +type ChestStrategy struct{} + +func (s *ChestStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + user.ChestCount++ + ctx.Logger.Info("Player %s found a chest! Total chests: %d", user.UserID, user.ChestCount) + ctx.Logic.BroadcastEvent(core.GameEvent{ + Type: "item", PlayerID: user.UserID, PlayerName: user.Username, ItemID: "chest", Value: user.ChestCount, + Message: "📦 发现宝箱!游戏结束后可获得奖励!", + }) + return true +} + +// --- Curse --- +type CurseStrategy struct{} + +func (s *CurseStrategy) Use(state *core.GameState, user *core.Player, ctx ItemContext) bool { + user.Curse = true + // 逻辑上诅咒只是设置标志。原始代码中没有广播? + // 嗯,原始代码确实只是设置了标志。 + // 我们可以选择添加广播,或者让客户端查看状态图标。 + // 如果需要清晰度,我们可以添加广播,但原始代码在 resolveItem 的 switch 诅咒分支中并没有专门的广播... + // 等等,检查原始代码。 + // case "curse": player.Curse = true。就这样。 + // 我将保持最小化。 + return true +} diff --git a/server/items/interface.go b/server/items/interface.go new file mode 100644 index 0000000..b397117 --- /dev/null +++ b/server/items/interface.go @@ -0,0 +1,30 @@ +package items + +import ( + "wuziqi-server/core" + + "github.com/heroiclabs/nakama-common/runtime" +) + +// GameLogic 接口定义了道具需要从游戏引擎调用的方法 +// 这避免了 items 和 logic 包之间的循环依赖 +type GameLogic interface { + ApplyDamage(state *core.GameState, target *core.Player, amount int, isItemEffect bool) + HealPlayer(player *core.Player, amount int) + GetRandomAliveTarget(state *core.GameState, excludeID string) *core.Player + BroadcastEvent(event core.GameEvent) + SendPrivateEvent(targetID string, event core.GameEvent) +} + +// ItemContext 捆绑了道具使用所需的依赖项 +type ItemContext struct { + Logger runtime.Logger + Dispatcher runtime.MatchDispatcher + Logic GameLogic +} + +// ItemStrategy 定义了特定道具的行为 +type ItemStrategy interface { + // Use 应用道具效果。如果消耗则返回 true。 + Use(state *core.GameState, user *core.Player, ctx ItemContext) bool +} diff --git a/server/items/items_test.go b/server/items/items_test.go new file mode 100644 index 0000000..2d7e928 --- /dev/null +++ b/server/items/items_test.go @@ -0,0 +1,338 @@ +package items + +import ( + "testing" + "wuziqi-server/core" + + "github.com/heroiclabs/nakama-common/runtime" +) + +// --- Mocks --- + +type MockLogger struct { + runtime.Logger // Embed interface to skip implementing all methods +} + +func (m *MockLogger) Info(format string, v ...interface{}) {} +func (m *MockLogger) Warn(format string, v ...interface{}) {} +func (m *MockLogger) Error(format string, v ...interface{}) {} +func (m *MockLogger) Debug(format string, v ...interface{}) {} + +type MockDispatcher struct { + runtime.MatchDispatcher +} + +func (m *MockDispatcher) BroadcastMessage(opCode int64, data []byte, presences []runtime.Presence, sender runtime.Presence, reliable bool) error { + return nil +} + +type MockGameLogic struct { + LastEvent *core.GameEvent + HealCalled bool + DamageCalls []struct { + TargetID string + Amount int + } +} + +func (m *MockGameLogic) ApplyDamage(state *core.GameState, target *core.Player, amount int, isItemEffect bool) { + m.DamageCalls = append(m.DamageCalls, struct { + TargetID string + Amount int + }{TargetID: target.UserID, Amount: amount}) + // Simulate basic damage for test state + target.HP -= amount +} + +func (m *MockGameLogic) HealPlayer(player *core.Player, amount int) { + m.HealCalled = true + player.HP += amount + if player.HP > player.MaxHP { + player.HP = player.MaxHP + } +} + +func (m *MockGameLogic) GetRandomAliveTarget(state *core.GameState, excludeID string) *core.Player { + for _, p := range state.Players { + if p.UserID != excludeID && p.HP > 0 { + return p + } + } + return nil +} + +func (m *MockGameLogic) BroadcastEvent(event core.GameEvent) { + m.LastEvent = &event +} + +// --- Helper --- + +func createTestContext(logic GameLogic) ItemContext { + return ItemContext{ + Logger: &MockLogger{}, + Dispatcher: &MockDispatcher{}, + Logic: logic, + } +} + +func createTestState() (*core.GameState, *core.Player) { + p1 := &core.Player{UserID: "p1", Username: "Player1", HP: 3, MaxHP: 4, Character: "dog", RevealedCells: make(map[int]string)} + p2 := &core.Player{UserID: "p2", Username: "Player2", HP: 4, MaxHP: 4, Character: "cat", RevealedCells: make(map[int]string)} + + grid := make([]*core.GridCell, 100) + for i := range grid { + grid[i] = &core.GridCell{Type: "empty", Revealed: false} + } + + state := &core.GameState{ + Players: map[string]*core.Player{ + "p1": p1, + "p2": p2, + }, + Grid: grid, + } + return state, p1 +} + +// --- Tests --- + +func TestMedkit(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &MedkitStrategy{} + + // Setup: Poison user + user.Poisoned = true + user.PoisonSteps = 2 + user.HP = 2 + + // Test Normal Use + consumed := strategy.Use(state, user, ctx) + + if !consumed { + t.Error("Medkit should be consumed") + } + if user.Poisoned { + t.Error("Medkit should cure poison") + } + if user.PoisonSteps != 0 { + t.Error("Medkit should reset poison steps") + } + if user.HP != 3 { + t.Errorf("Medkit should heal 1 HP, got %d", user.HP) + } + if mockLogic.LastEvent == nil || mockLogic.LastEvent.ItemID != "medkit" { + t.Error("Medkit should broadcast event") + } + + // Test Elephant Refusal + user.Character = "elephant" + consumed = strategy.Use(state, user, ctx) + if consumed { + t.Error("Medkit should NOT be consumed by Elephant") + } +} + +func TestBombTimer(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &BombTimerStrategy{} + + consumed := strategy.Use(state, user, ctx) + + if !consumed { + t.Error("BombTimer should be consumed") + } + if user.TimeBombTurns != 3 { + t.Errorf("BombTimer should set turns to 3, got %d", user.TimeBombTurns) + } +} + +func TestPoison(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &PoisonStrategy{} + + // Target is p2 (available) + consumed := strategy.Use(state, user, ctx) + + if !consumed { + t.Error("Poison should be consumed") + } + target := state.Players["p2"] + if !target.Poisoned { + t.Error("Target should be poisoned") + } + + // Test Sloth Resistance + target.Character = "sloth" + target.Poisoned = false + strategy.Use(state, user, ctx) + if target.Poisoned { + t.Error("Sloth should resist poison") + } +} + +func TestShield(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &ShieldStrategy{} + + if user.Shield { + t.Error("User should not have shield initially") + } + + consumed := strategy.Use(state, user, ctx) + + if !consumed { + t.Error("Shield should be consumed") + } + if !user.Shield { + t.Error("User should have shield") + } +} + +func TestSkip(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &SkipStrategy{} + + consumed := strategy.Use(state, user, ctx) + if !consumed { + t.Error("Skip should be consumed") + } + if !user.SkipTurn { + t.Error("User should skip turn") + } + if !user.Shield { + t.Error("Skip card should grant shield") + } + + // Test Elephant + user.Character = "elephant" + user.SkipTurn = false + consumed = strategy.Use(state, user, ctx) + if consumed { + t.Error("Elephant should refuse skip") + } +} + +func TestMagnifier(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &MagnifierStrategy{} + + // Make sure grid has items to reveal + state.Grid[5].Type = "bomb" + + consumed := strategy.Use(state, user, ctx) + if !consumed { + t.Error("Magnifier should be consumed") + } + if len(user.RevealedCells) != 1 { + t.Errorf("Should reveal 1 cell, got %d", len(user.RevealedCells)) + } +} + +func TestKnife(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &KnifeStrategy{} + + // Case 1: Normal Knife (1 dmg to random target) + consumed := strategy.Use(state, user, ctx) + if !consumed { + t.Error("Knife should be consumed") + } + if len(mockLogic.DamageCalls) != 1 { + t.Errorf("Knife should cause 1 damage call, got %d", len(mockLogic.DamageCalls)) + } + if mockLogic.DamageCalls[0].TargetID != "p2" || mockLogic.DamageCalls[0].Amount != 1 { + t.Error("Knife should deal 1 dmg to p2") + } + + // Case 2: Tiger Knife (2 dmg AOE) + mockLogic.DamageCalls = nil // reset + user.Character = "tiger" + + // Add a third player to verify AOE + state.Players["p3"] = &core.Player{UserID: "p3", HP: 4, MaxHP: 4} + + strategy.Use(state, user, ctx) + if len(mockLogic.DamageCalls) != 2 { + t.Errorf("Tiger Knife should hit all enemies (2), got %d", len(mockLogic.DamageCalls)) + } + if mockLogic.DamageCalls[0].Amount != 2 { + t.Error("Tiger Knife should deal 2 dmg") + } +} + +func TestRevive(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &ReviveStrategy{} + + consumed := strategy.Use(state, user, ctx) + if !consumed { + t.Error("Revive should be consumed") + } + if !user.Revive { + t.Error("User should have Revive flag") + } + + // Elephant + user.Character = "elephant" + user.Revive = false + consumed = strategy.Use(state, user, ctx) + if consumed { + t.Error("Elephant should check privilege") + } +} + +func TestLightning(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &LightningStrategy{} + + strategy.Use(state, user, ctx) + + // Should hit ALL players (including self per logic? "Lightning hits ALL players") + // Let's check effects.go: "for _, p := range state.Players { ApplyDamage }" + // Yes, hits everyone. + if len(mockLogic.DamageCalls) != 2 { + t.Errorf("Lightning should hit 2 players, got %d", len(mockLogic.DamageCalls)) + } +} + +func TestChest(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &ChestStrategy{} + + strategy.Use(state, user, ctx) + if user.ChestCount != 1 { + t.Error("Chest count should increment") + } +} + +func TestCurse(t *testing.T) { + state, user := createTestState() + mockLogic := &MockGameLogic{} + ctx := createTestContext(mockLogic) + strategy := &CurseStrategy{} + + strategy.Use(state, user, ctx) + if !user.Curse { + t.Error("User should be cursed") + } +} diff --git a/server/items/manager.go b/server/items/manager.go new file mode 100644 index 0000000..0de7b95 --- /dev/null +++ b/server/items/manager.go @@ -0,0 +1,45 @@ +package items + +import ( + "wuziqi-server/core" +) + +type ItemManager struct { + strategies map[string]ItemStrategy +} + +func NewItemManager() *ItemManager { + m := &ItemManager{ + strategies: make(map[string]ItemStrategy), + } + m.registerDefaults() + return m +} + +func (m *ItemManager) registerDefaults() { + m.Register("medkit", &MedkitStrategy{}) + m.Register("bomb_timer", &BombTimerStrategy{}) + m.Register("poison", &PoisonStrategy{}) + m.Register("shield", &ShieldStrategy{}) + m.Register("skip", &SkipStrategy{}) + m.Register("magnifier", &MagnifierStrategy{}) + m.Register("knife", &KnifeStrategy{}) + m.Register("revive", &ReviveStrategy{}) + m.Register("lightning", &LightningStrategy{}) + m.Register("chest", &ChestStrategy{}) + m.Register("curse", &CurseStrategy{}) +} + +func (m *ItemManager) Register(itemID string, strategy ItemStrategy) { + m.strategies[itemID] = strategy +} + +func (m *ItemManager) UseItem(state *core.GameState, user *core.Player, itemID string, ctx ItemContext) bool { + strategy, exists := m.strategies[itemID] + if !exists { + ctx.Logger.Error("No strategy found for item: %s", itemID) + return false + } + ctx.Logger.Info("Player %s used item: %s", user.UserID, itemID) + return strategy.Use(state, user, ctx) +} diff --git a/server/logic/combat.go b/server/logic/combat.go new file mode 100644 index 0000000..1ca4c0b --- /dev/null +++ b/server/logic/combat.go @@ -0,0 +1,241 @@ +package logic + +import ( + "encoding/json" + "fmt" + "math/rand" + "time" + "wuziqi-server/core" + "wuziqi-server/items" +) + +// ApplyDamage 处理伤害计算、减免和效果 +func (e *GameEngine) ApplyDamage(state *core.GameState, target *core.Player, amount int, isItemEffect bool) { + if target.HP <= 0 { + return + } + + // 1. 护盾减免 + if target.Shield { + target.Shield = false + e.Logger.Info("Player %s (%s) blocked damage with shield", target.UserID, target.Character) + return // Blocked + } + + // 2. 角色特质修改 + // 猫咪天赋: 伤害强制为1 + // 树懒: 如果是定时炸弹(item effect logic handled in engine.go), here we just take raw amount + // But CharacterManager.OnDamageTaken handles Cat logic + amount = e.CharManager.OnDamageTaken(target, amount, isItemEffect) + e.Logger.Info("ApplyDamage: PlayerID=%s, Char=%s, FinalAmount=%d", target.UserID, target.Character, amount) + + // 3. 诅咒修改 + // 非猫角色受到诅咒伤害翻倍 + if target.Curse && target.Character != "cat" { + amount *= 2 + target.Curse = false + } else if target.Character == "cat" { + target.Curse = false // 即使没有增加伤害也清除诅咒 + } + + // 4. 应用伤害 + target.HP -= amount + + // 5. 鸡的特质(受伤获得道具) + if itemID, triggered := e.CharManager.TryTriggerChickenAbility(target); triggered { + switch itemID { + case "skip": + target.SkipTurn = true + target.Shield = true + case "shield": + target.Shield = true + case "magnifier": + // 触发放大镜逻辑 + if e.ItemManager != nil { + // 这里不方便使用 ItemStrategy,因为上下文需要 ItemContext + // 如果构建上下文,我们可以直接执行逻辑或重用策略 + // 为了简单起见,我们直接实现逻辑以避免开销 + for i := 0; i < 100; i++ { + cellIdx := rand.Intn(len(state.Grid)) + if !state.Grid[cellIdx].Revealed { + cellType := state.Grid[cellIdx].Type + if state.Grid[cellIdx].Type == "item" { + cellType = state.Grid[cellIdx].ItemID + } + target.RevealedCells[cellIdx] = cellType + break + } + } + } + } + e.Logger.Info("Chicken %s triggered ability, got %s", target.UserID, itemID) + e.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: target.UserID, PlayerName: target.Username, + Message: fmt.Sprintf("🐔 鸡你太美!受伤触发天赋,获得了 %s!", itemID), + }) + } + + // 6. 死亡检查 + if target.HP <= 0 { + if target.Revive { + target.Revive = false + target.HP = 1 + e.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: target.UserID, PlayerName: target.Username, + Message: "💖 复活甲生效,免疫死亡!", + }) + } else if e.CharManager.TryTriggerHippoResist(target) { + target.HP = 1 + e.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: target.UserID, PlayerName: target.Username, + Message: "🦛 河马皮糙肉厚,免疫了一次死亡!", + }) + } + } + + if target.HP < 0 { + target.HP = 0 + } +} + +func (e *GameEngine) HealPlayer(p *core.Player, amount int) { + if p.HP < p.MaxHP { + p.HP += amount + if p.HP > p.MaxHP { + p.HP = p.MaxHP + } + } +} + +// HandleMove 处理玩家的移动 +func (e *GameEngine) HandleMove(state *core.GameState, userID string, cellIndex int) { + e.Logger.Info("HandleMove: UserID=%s, Index=%d", userID, cellIndex) + + // 验证回合 + if len(state.TurnOrder) == 0 { + return + } + currentUserID := state.TurnOrder[state.CurrentTurnIndex] + if userID != currentUserID { + e.Logger.Warn("HandleMove rejected: Turn mismatch. Incoming=%s, Expected=%s", userID, currentUserID) + return + } + + // 验证格子 + if cellIndex < 0 || cellIndex >= len(state.Grid) { + return + } + cell := state.Grid[cellIndex] + if cell.Revealed { + return + } + + player := state.Players[userID] + if player == nil { + return + } + + // 执行移动 + cell.Revealed = true + state.GlobalTurnCount++ + state.LastMoveTimestamp = time.Now().Unix() + + // 狗狗技能:基于狗狗自身的移动步数触发 + if player.Character == "dog" { + player.DogStepCount++ + interval := 6 + if len(state.Players) >= 6 { + interval = 9 + } + if player.DogStepCount%interval == 0 { + // 触发放大镜效果 + for i := 0; i < 100; i++ { + idx := rand.Intn(len(state.Grid)) + if !state.Grid[idx].Revealed { + cellType := state.Grid[idx].Type + if state.Grid[idx].Type == "item" { + cellType = state.Grid[idx].ItemID + } + player.RevealedCells[idx] = cellType + coords := state.FormatCoordinates(idx) + contentDesc := core.TranslateCellType(cellType) + msg := fmt.Sprintf("🐶 狗狗触发嗅觉天赋(第%d步),发现 %s 格子是 [%s]!", player.DogStepCount, coords, contentDesc) + + e.SendPrivateEvent(player.UserID, core.GameEvent{ + Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, + Message: msg, + CellIndex: idx, CellType: cellType, + }) + break + } + } + } + } + + // 猴子技能 + if player.Character == "monkey" && player.MonkeyBananaCount < 2 && rand.Float32() < 0.15 { + e.HealPlayer(player, 1) + player.MonkeyBananaCount++ + e.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, + Value: 1, Message: "🍌 猴子发现了香蕉,回复1点血量!", + }) + } + + // 处理内容 + if cell.Type == "bomb" { + dmg := 2 + // 树懒踩炸弹伤害减半 + if player.Character == "sloth" { + dmg = 1 + } + e.Logger.Info("BombHit: UserID=%s, Char=%s, Dmg=%d", player.UserID, player.Character, dmg) + e.BroadcastEvent(core.GameEvent{ + Type: "damage", PlayerID: player.UserID, PlayerName: player.Username, + Value: dmg, Message: fmt.Sprintf("💣 踩到炸弹,受到%d点伤害!", dmg), + }) + e.ApplyDamage(state, player, dmg, false) + + } else if cell.Type == "item" { + if player.Character == "hippo" { + e.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, + ItemID: cell.ItemID, Message: "🦛 河马无法拾取道具!", + }) + } else { + // 构建上下文 + ctx := items.ItemContext{ + Logger: e.Logger, + Dispatcher: e.Dispatcher, + Logic: e, + } + e.ItemManager.UseItem(state, player, cell.ItemID, ctx) + } + + } else if cell.Type == "empty" { + if cell.NeighborBombs == 0 { + revealed := RevealSafeArea(state, cellIndex) + if len(revealed) > 1 { + e.BroadcastEvent(core.GameEvent{ + Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, + Value: len(revealed), Message: fmt.Sprintf("🔓 发现安全区域,自动揭示了 %d 个格子!", len(revealed)), + }) + } + } + } + + // 游戏结束和回合推进 + // 无论如何先尝试推进回合(特别是如果当前玩家刚刚死亡/跳过) + e.AdvanceTurn(state) + + // 然后检查游戏是否结束 + if e.CheckGameOver(state) { + e.Logger.Info("Match %s ended during HandleMove for user %s", state.WinnerID, userID) + return + } + + // 广播常规状态更新 + e.Logger.Debug("Broadcasting state update for match, current turn index: %d, alive players: %d", state.CurrentTurnIndex, len(state.Players)) + updateData, _ := json.Marshal(state.Sanitize()) + e.Dispatcher.BroadcastMessage(core.OpCodeUpdateState, updateData, nil, nil, true) +} diff --git a/server/logic/comprehensive_test.go b/server/logic/comprehensive_test.go new file mode 100644 index 0000000..920dcd3 --- /dev/null +++ b/server/logic/comprehensive_test.go @@ -0,0 +1,749 @@ +package logic + +import ( + "testing" + "wuziqi-server/characters" + "wuziqi-server/core" +) + +// ============================================================ +// 道具逻辑测试 (11个道具) +// ============================================================ + +// 1. 医疗包:恢复1点血量,可以解除中毒效果 +func TestItem_Medkit_Basic(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "医疗包-基础恢复", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 3}, // MaxHP 会自动设置为4 + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "medkit"}, + }, + Actions: []GameAction{ + {Type: "damage", PlayerID: "p1", Value: 1}, // 先扣1血到2 + {Type: "move", PlayerID: "p1", Value: 0}, // 使用医疗包恢复到3 + }, + Checks: []ScenarioCheck{ + {PlayerID: "p1", Field: "hp", Expected: 3, Message: "医疗包应该恢复1点血量"}, + }, + }) +} + +func TestItem_Medkit_CurePoison(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "医疗包-解除中毒", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4, Poisoned: true}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "medkit"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p1", Field: "poisoned", Expected: false, Message: "医疗包应该解除中毒"}, + }, + }) +} + +func TestItem_Medkit_ElephantCannotUse(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "医疗包-大象无法使用", + Players: []PlayerSetup{ + {ID: "elephant", Character: "elephant", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "medkit"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "elephant", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "elephant", Field: "hp", Expected: 4, Message: "大象无法使用医疗包"}, + }, + }) +} + +// 2. 定时炸弹:踩到后3回合后爆炸,扣除2点血量 +func TestItem_BombTimer_Activation(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "cat", HP: 3}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "bomb_timer"}, + }, + }) + + // p1 踩到定时炸弹 + engine.HandleMove(state, "p1", 0) + + if state.Players["p1"].TimeBombTurns != 3 { + t.Errorf("定时炸弹应该设置3回合倒计时,got %d", state.Players["p1"].TimeBombTurns) + } +} + +func TestItem_BombTimer_Explosion(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4, TimeBomb: 1}, // 1回合后爆炸 + {ID: "p2", Character: "cat", HP: 3}, + }, + Grid: []CellSetup{}, + }) + + // 推进回合,炸弹应该爆炸 + state.CurrentTurnIndex = 1 // 设置为p2,下一个是p1 + engine.AdvanceTurn(state) + + if state.Players["p1"].HP != 2 { + t.Errorf("定时炸弹爆炸应该造成2点伤害,HP应该是2,got %d", state.Players["p1"].HP) + } +} + +func TestItem_BombTimer_SlothReduction(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "sloth", Character: "sloth", HP: 4, TimeBomb: 1}, + {ID: "p2", Character: "cat", HP: 3}, + }, + Grid: []CellSetup{}, + }) + + state.CurrentTurnIndex = 1 + engine.AdvanceTurn(state) + + if state.Players["sloth"].HP != 3 { + t.Errorf("树懒定时炸弹伤害应该减为1,HP应该是3,got %d", state.Players["sloth"].HP) + } +} + +// 3. 毒药瓶:随机让一个存活玩家中毒,每2回合扣1血 +func TestItem_Poison_BasicEffect(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "monkey", HP: 4}, // 非树懒 + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "poison"}, + }, + }) + + engine.HandleMove(state, "p1", 0) + + // p2 应该中毒(只有一个非使用者目标) + if !state.Players["p2"].Poisoned { + t.Error("毒药应该使目标中毒") + } +} + +func TestItem_Poison_DamagePerTwoTurns(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4, Poisoned: true}, + {ID: "p2", Character: "cat", HP: 3}, + }, + Grid: []CellSetup{}, + }) + + // 第1回合:PoisonSteps=1,不扣血 + state.CurrentTurnIndex = 1 + engine.AdvanceTurn(state) + if state.Players["p1"].HP != 4 { + t.Errorf("第1回合不应该扣血,got HP=%d", state.Players["p1"].HP) + } + + // 第2回合:PoisonSteps=2,扣1血 + state.CurrentTurnIndex = 1 + engine.AdvanceTurn(state) + if state.Players["p1"].HP != 3 { + t.Errorf("第2回合应该扣1血,got HP=%d", state.Players["p1"].HP) + } +} + +func TestItem_Poison_SlothImmune(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "毒药-树懒免疫", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "sloth", Character: "sloth", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "poison"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "sloth", Field: "poisoned", Expected: false, Message: "树懒应该免疫毒药"}, + }, + }) +} + +// 4. 护盾:免疫1次伤害 +func TestItem_Shield_BlockDamage(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "护盾-抵挡伤害", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4, Shield: true}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p1", Field: "hp", Expected: 4, Message: "护盾应该抵挡炸弹伤害"}, + {PlayerID: "p1", Field: "shield", Expected: false, Message: "护盾应该被消耗"}, + }, + }) +} + +// 5. 好人卡:跳过回合+护盾 +func TestItem_Skip_Effect(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "好人卡-跳过回合并获得护盾", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "cat", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "skip"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p1", Field: "skip_turn", Expected: true, Message: "应该设置跳过回合"}, + {PlayerID: "p1", Field: "shield", Expected: true, Message: "应该获得护盾"}, + {PlayerID: "p2", Field: "current_turn", Expected: true, Message: "回合应该切换到p2"}, + }, + }) +} + +func TestItem_Skip_ElephantCannotUse(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "好人卡-大象无法使用", + Players: []PlayerSetup{ + {ID: "elephant", Character: "elephant", HP: 5}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "skip"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "elephant", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "elephant", Field: "skip_turn", Expected: false, Message: "大象无法使用好人卡"}, + {PlayerID: "elephant", Field: "shield", Expected: false, Message: "大象不应该获得护盾"}, + }, + }) +} + +// 6. 放大镜:透视一个随机格子 +func TestItem_Magnifier_RevealCell(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "magnifier"}, + {Index: 5, Type: "bomb"}, // 未揭示的炸弹 + }, + }) + + engine.HandleMove(state, "p1", 0) + + if len(state.Players["p1"].RevealedCells) == 0 { + t.Error("放大镜应该揭示一个格子") + } +} + +// 7. 飞刀:对随机敌人造成1点伤害 +func TestItem_Knife_BasicDamage(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "飞刀-基础伤害", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "monkey", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "knife"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p2", Field: "hp", Expected: 3, Message: "飞刀应该造成1点伤害"}, + {PlayerID: "p1", Field: "hp", Expected: 4, Message: "使用者不受伤害"}, + }, + }) +} + +func TestItem_Knife_TigerAOE(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "飞刀-老虎在场变全体2点伤害", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "monkey", HP: 4}, + {ID: "tiger", Character: "tiger", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "knife"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p2", Field: "hp", Expected: 2, Message: "老虎在场,飞刀应该造成2点伤害"}, + {PlayerID: "tiger", Field: "hp", Expected: 2, Message: "老虎自己也受2点伤害"}, + {PlayerID: "p1", Field: "hp", Expected: 4, Message: "使用者不受伤害"}, + }, + }) +} + +// 8. 复活甲:免疫一次死亡,保留1点血量 +func TestItem_Revive_SaveFromDeath(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "复活甲-免疫死亡", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 1, Revive: true}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p1", Field: "hp", Expected: 1, Message: "复活甲应该保留1点血量"}, + {PlayerID: "p1", Field: "alive", Expected: true, Message: "玩家应该存活"}, + {PlayerID: "p1", Field: "revive", Expected: false, Message: "复活甲应该被消耗"}, + }, + }) +} + +func TestItem_Revive_ElephantCannotUse(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "复活甲-大象无法使用", + Players: []PlayerSetup{ + {ID: "elephant", Character: "elephant", HP: 5}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "revive"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "elephant", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "elephant", Field: "revive", Expected: false, Message: "大象无法使用复活甲"}, + }, + }) +} + +// 9. 闪电:对所有玩家造成1点伤害 +func TestItem_Lightning_AllDamage(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "闪电-全体伤害", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "monkey", HP: 4}, + {ID: "p3", Character: "tiger", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "lightning"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p1", Field: "hp", Expected: 3, Message: "闪电对使用者也造成伤害"}, + {PlayerID: "p2", Field: "hp", Expected: 3, Message: "闪电对p2造成伤害"}, + {PlayerID: "p3", Field: "hp", Expected: 3, Message: "闪电对p3造成伤害"}, + }, + }) +} + +// 10. 宝箱:游戏结束后获得奖励 +func TestItem_Chest_Counter(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "chest"}, + }, + }) + + engine.HandleMove(state, "p1", 0) + + if state.Players["p1"].ChestCount != 1 { + t.Errorf("宝箱计数应该是1,got %d", state.Players["p1"].ChestCount) + } +} + +// 11. 诅咒:下次受伤翻倍 +func TestItem_Curse_DoubleDamage(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "诅咒-伤害翻倍", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4, Curse: true}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p1", Value: 0}, // 炸弹2伤害 * 2 = 4 + }, + Checks: []ScenarioCheck{ + {PlayerID: "p1", Field: "hp", Expected: 0, Message: "诅咒应该让伤害翻倍"}, + {PlayerID: "p1", Field: "curse", Expected: false, Message: "诅咒应该被消耗"}, + }, + }) +} + +func TestItem_Curse_CatIgnores(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "诅咒-猫咪无视翻倍", + Players: []PlayerSetup{ + {ID: "cat", Character: "cat", HP: 3, Curse: true}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "cat", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "cat", Field: "hp", Expected: 2, Message: "猫咪诅咒也只受1点伤害"}, + }, + }) +} + +// ============================================================ +// 角色逻辑测试 (8个角色) +// ============================================================ + +// 1. 大象:5点血,无法使用好人卡/医疗包/复活甲 +func TestCharacter_Elephant_HP(t *testing.T) { + charMgr := characters.NewCharacterManager(nil) + hp := charMgr.GetInitialHP("elephant", 4) + if hp != 5 { + t.Errorf("大象初始血量应该是5,got %d", hp) + } +} + +func TestCharacter_Elephant_ItemRestrictions(t *testing.T) { + // 已在上面的道具测试中覆盖 + t.Log("大象道具限制已在道具测试中覆盖") +} + +// 2. 猫咪:3点血,所有伤害强制为1(包括诅咒) +func TestCharacter_Cat_HP(t *testing.T) { + charMgr := characters.NewCharacterManager(nil) + hp := charMgr.GetInitialHP("cat", 4) + if hp != 3 { + t.Errorf("猫咪初始血量应该是3,got %d", hp) + } +} + +func TestCharacter_Cat_DamageCap(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "猫咪-伤害限制为1", + Players: []PlayerSetup{ + {ID: "cat", Character: "cat", HP: 3}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, // 2点伤害 + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "cat", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "cat", Field: "hp", Expected: 2, Message: "猫咪受到任何伤害都只扣1点"}, + }, + }) +} + +// 3. 汪汪:4(6)人赛每6(9)回合触发放大镜 +func TestCharacter_Dog_MagnifierAbility(t *testing.T) { + // 由于狗狗技能依赖于 GlobalTurnCount % 6 == 0, + // 且只有狗狗自己行动时才会触发,我们直接测试逻辑 + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "dog", Character: "dog", HP: 4}, + }, + Grid: []CellSetup{}, + }) + + // 创建足够的格子 + state.Grid = make([]*core.GridCell, 100) + for i := range state.Grid { + state.Grid[i] = &core.GridCell{Type: "empty", Revealed: false} + } + state.Grid[99].Type = "bomb" // 放一个未揭示的炸弹 + + // 直接设置 GlobalTurnCount 为 5,下一次操作将使其变为 6 + state.GlobalTurnCount = 5 + + // 狗狗操作,GlobalTurnCount 变为 6,6 % 6 == 0,应该触发 + engine.HandleMove(state, "dog", 0) + + t.Logf("操作后 GlobalTurnCount=%d, RevealedCells=%d", + state.GlobalTurnCount, len(state.Players["dog"].RevealedCells)) + + // 检查是否触发了放大镜 + if len(state.Players["dog"].RevealedCells) == 0 { + t.Errorf("狗狗应该在 GlobalTurnCount=6 时触发放大镜能力") + } else { + t.Logf("狗狗放大镜触发成功,揭示了 %d 个格子", len(state.Players["dog"].RevealedCells)) + } +} + +// 4. 吉吉国王(猴子):每回合15%概率获得香蕉恢复1血,最多2次 +func TestCharacter_Monkey_BananaAbility(t *testing.T) { + // 由于是概率性的,我们测试计数器限制 + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "monkey", Character: "monkey", HP: 2}, + }, + Grid: []CellSetup{}, + }) + + // 手动设置已触发2次 + state.Players["monkey"].MonkeyBananaCount = 2 + + // 模拟移动,不应该再触发 + for i := 0; i < 10; i++ { + state.Grid = append(state.Grid, &core.GridCell{Type: "empty", Revealed: false}) + } + + initialHP := state.Players["monkey"].HP + engine.HandleMove(state, "monkey", 0) + + // 由于已经触发2次,不应该再恢复 + if state.Players["monkey"].MonkeyBananaCount > 2 { + t.Error("猴子香蕉能力最多触发2次") + } + + t.Logf("猴子HP变化: %d -> %d (计数: %d)", initialHP, state.Players["monkey"].HP, state.Players["monkey"].MonkeyBananaCount) +} + +// 5. 坤坤(小鸡):受伤8%概率获得道具,最多2次 +func TestCharacter_Chicken_ItemOnDamage(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "chicken", Character: "chicken", HP: 4}, + }, + Grid: []CellSetup{}, + }) + + // 手动设置已触发2次 + state.Players["chicken"].ChickenItemCount = 2 + + // 造成伤害,不应该再触发 + engine.ApplyDamage(state, state.Players["chicken"], 1, false) + + if state.Players["chicken"].ChickenItemCount > 2 { + t.Error("小鸡道具能力最多触发2次") + } +} + +// 6. 懒懒(树懒):免疫毒药,炸弹伤害减半 +func TestCharacter_Sloth_PoisonImmune(t *testing.T) { + // 已在毒药测试中覆盖 + t.Log("树懒毒药免疫已在道具测试中覆盖") +} + +func TestCharacter_Sloth_BombDamageReduction(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "树懒-炸弹伤害减半", + Players: []PlayerSetup{ + {ID: "sloth", Character: "sloth", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "sloth", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "sloth", Field: "hp", Expected: 3, Message: "树懒踩炸弹只受1点伤害"}, + }, + }) +} + +// 7. 河马:无法拾取道具,55%概率免疫死亡(最多1次) +func TestCharacter_Hippo_CannotPickItems(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "河马-无法拾取道具", + Players: []PlayerSetup{ + {ID: "hippo", Character: "hippo", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "shield"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "hippo", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "hippo", Field: "shield", Expected: false, Message: "河马无法拾取道具"}, + }, + }) +} + +func TestCharacter_Hippo_DeathResistOnce(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "hippo", Character: "hippo", HP: 1}, + }, + Grid: []CellSetup{}, + }) + + // 模拟多次死亡尝试 + deathResistTriggered := 0 + for i := 0; i < 100; i++ { + // 重置状态 + state.Players["hippo"].HP = 1 + state.Players["hippo"].HippoDeathImmune = false + + engine.ApplyDamage(state, state.Players["hippo"], 10, false) + + if state.Players["hippo"].HP > 0 { + deathResistTriggered++ + if state.Players["hippo"].HippoDeathImmune != true { + t.Error("河马死亡抵抗标志应该被设置") + } + } + } + + t.Logf("河马死亡抵抗触发次数: %d/100 (预期约55%%)", deathResistTriggered) + + // 测试已触发后不再生效 + state.Players["hippo"].HP = 1 + state.Players["hippo"].HippoDeathImmune = true + engine.ApplyDamage(state, state.Players["hippo"], 10, false) + + if state.Players["hippo"].HP > 0 { + t.Error("河马死亡抵抗应该只能触发一次") + } +} + +// 8. 老虎:飞刀变全体2点伤害 +func TestCharacter_Tiger_KnifeEnhance(t *testing.T) { + // 已在飞刀测试中覆盖 + t.Log("老虎飞刀增强已在道具测试中覆盖") +} + +// ============================================================ +// 游戏流程测试 +// ============================================================ + +func TestGameFlow_GameOver(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "cat", HP: 1}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + }) + + // p2 踩炸弹死亡 + state.CurrentTurnIndex = 1 + engine.HandleMove(state, "p2", 0) + + if state.WinnerID != "p1" { + t.Errorf("p1应该获胜,got winner=%s", state.WinnerID) + } + if state.GameStarted { + t.Error("游戏应该结束") + } +} + +func TestGameFlow_SafeAreaExpansion(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + }, + Grid: []CellSetup{}, + }) + + // 创建3x3网格,右下角有炸弹 + state.GridSize = 3 + state.Grid = make([]*core.GridCell, 9) + for i := range state.Grid { + state.Grid[i] = &core.GridCell{Type: "empty", Revealed: false, NeighborBombs: 0} + } + state.Grid[8].Type = "bomb" + state.Grid[4].NeighborBombs = 1 // 中间格子有1个邻居炸弹 + state.Grid[5].NeighborBombs = 1 + state.Grid[7].NeighborBombs = 1 + + // 点击左上角 + engine.HandleMove(state, "p1", 0) + + // 统计揭示的格子 + revealedCount := 0 + for _, cell := range state.Grid { + if cell.Revealed { + revealedCount++ + } + } + + t.Logf("揭示的格子数: %d", revealedCount) + + if revealedCount < 5 { + t.Errorf("安全区扩散应该揭示更多格子,只揭示了 %d 个", revealedCount) + } + + if state.Grid[8].Revealed { + t.Error("炸弹不应该被揭示") + } +} + +func TestGameFlow_TurnAdvance(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "cat", HP: 3}, + {ID: "p3", Character: "tiger", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "empty"}, + }, + }) + + // p1 移动 + engine.HandleMove(state, "p1", 0) + + // 回合应该推进到 p2 + if state.TurnOrder[state.CurrentTurnIndex] != "p2" { + t.Errorf("回合应该推进到p2,当前是 %s", state.TurnOrder[state.CurrentTurnIndex]) + } +} + +func TestGameFlow_SkipTurn(t *testing.T) { + engine, state := createScenarioState(GameScenario{ + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "cat", HP: 3, SkipTurn: true}, + {ID: "p3", Character: "tiger", HP: 4}, + }, + Grid: []CellSetup{}, + }) + + // 从p1推进,应该跳过p2到p3 + state.CurrentTurnIndex = 0 + engine.AdvanceTurn(state) + + if state.TurnOrder[state.CurrentTurnIndex] != "p3" { + t.Errorf("应该跳过p2到p3,当前是 %s", state.TurnOrder[state.CurrentTurnIndex]) + } +} diff --git a/server/logic/engine.go b/server/logic/engine.go new file mode 100644 index 0000000..7eaa000 --- /dev/null +++ b/server/logic/engine.go @@ -0,0 +1,199 @@ +package logic + +import ( + "encoding/json" + "math/rand" + + "wuziqi-server/characters" + "wuziqi-server/config" + "wuziqi-server/core" + "wuziqi-server/items" + + "github.com/heroiclabs/nakama-common/runtime" +) + +type GameEngine struct { + CharManager *characters.CharacterManager + ItemManager *items.ItemManager + Dispatcher runtime.MatchDispatcher + Logger runtime.Logger + Presences map[string]runtime.Presence + DisconnectedPlayers map[string]*core.Player +} + +func NewGameEngine(logger runtime.Logger, dispatcher runtime.MatchDispatcher, charMgr *characters.CharacterManager, itemMgr *items.ItemManager, presences map[string]runtime.Presence, disconnected map[string]*core.Player) *GameEngine { + return &GameEngine{ + CharManager: charMgr, + ItemManager: itemMgr, + Dispatcher: dispatcher, + Logger: logger, + Presences: presences, + DisconnectedPlayers: disconnected, + } +} + +// BroadcastEvent 实现 items.GameLogic +func (e *GameEngine) BroadcastEvent(event core.GameEvent) { + core.BroadcastEvent(e.Dispatcher, event) +} + +// SendPrivateEvent 实现 items.GameLogic,仅向特定用户发送事件 +func (e *GameEngine) SendPrivateEvent(targetID string, event core.GameEvent) { + presence, ok := e.Presences[targetID] + if !ok { + e.Logger.Warn("SendPrivateEvent: Presence not found for user %s", targetID) + return + } + data, _ := json.Marshal(event) + e.Logger.Debug("SendPrivateEvent: UserID=%s, Type=%s, Msg=%s, FoundPresence=%v", targetID, event.Type, event.Message, ok) + // 第三个参数限制为仅包含该玩家的 presence 列表 + e.Dispatcher.BroadcastMessage(core.OpCodeGameEvent, data, []runtime.Presence{presence}, nil, true) +} + +// GetRandomAliveTarget 实现 items.GameLogic +func (e *GameEngine) GetRandomAliveTarget(state *core.GameState, excludeID string) *core.Player { + candidates := []*core.Player{} + for _, p := range state.Players { + if p.UserID != excludeID && p.HP > 0 { + candidates = append(candidates, p) + } + } + if len(candidates) == 0 { + return nil + } + return candidates[rand.Intn(len(candidates))] +} + +// CheckGameOver 检查游戏是否结束 +func (e *GameEngine) CheckGameOver(state *core.GameState) bool { + if !state.GameStarted { + return false + } + + alive := []string{} + for _, p := range state.Players { + if p.HP > 0 { + alive = append(alive, p.UserID) + } + } + // 关键修复:计入正在断线重连中的幸存玩家 + for _, p := range e.DisconnectedPlayers { + if p.HP > 0 { + alive = append(alive, p.UserID) + } + } + + if len(alive) <= 1 { + winnerID := "" + if len(alive) == 1 { + winnerID = alive[0] + } else if len(alive) == 0 { + winnerID = "draw" + } + state.WinnerID = winnerID + state.GameStarted = false + + // 使用真实用户ID向后端结算游戏 + if winnerID != "" && winnerID != "draw" { + winnerPlayer, ok := state.Players[winnerID] + if !ok { + // 如果在 Players 里没找到,尝试在断线列表中找(可能获胜者刚好断线) + winnerPlayer = e.DisconnectedPlayers[winnerID] + } + + if winnerPlayer != nil && winnerPlayer.RealUserID > 0 { + config.SettleGameWithBackend(e.Logger, winnerPlayer.RealUserID, winnerPlayer.Ticket, "", true, 100) + } else { + e.Logger.Error("Winner player %s has no RealUserID, cannot settle", winnerID) + } + } + + endDataBytes, _ := json.Marshal(map[string]interface{}{ + "winnerId": winnerID, + "gameState": state.Sanitize(), + }) + e.Dispatcher.BroadcastMessage(core.OpCodeGameOver, endDataBytes, nil, nil, true) + // 注意:BroadcastMessage 期望 []byte。 + // 我们应该在内部进行序列化。 + // 等等,Nakama 的 BroadcastMessage 接受 []byte 数据。 + // 我马上修好它。 + return true + } + return false +} + +// AdvanceTurn 处理回合推进、定时炸弹、毒药等。 +func (e *GameEngine) AdvanceTurn(state *core.GameState) { + scanCount := 0 + + for { + state.CurrentTurnIndex = (state.CurrentTurnIndex + 1) % len(state.TurnOrder) + scanCount++ + + // 回合计数:当回到第一个玩家时,全场轮次+1 + if state.CurrentTurnIndex == 0 { + state.Round++ + } + + // 如果每个人都跳过/死亡,防止死循环 + if scanCount > len(state.TurnOrder)*2 { + break + } + + nextUID := state.TurnOrder[state.CurrentTurnIndex] + nextPlayer := state.Players[nextUID] + + if nextPlayer == nil { + e.Logger.Warn("Player %s found in TurnOrder but missing from Players map", nextUID) + continue + } + + if nextPlayer.HP <= 0 { + continue + } + + // 处理定时炸弹倒计时 + if nextPlayer.TimeBombTurns > 0 { + nextPlayer.TimeBombTurns-- + if nextPlayer.TimeBombTurns == 0 { + // 轰!定时炸弹爆炸 + // 树懒受到的炸弹伤害减免是在 ApplyDamage 中处理的吗? + // 原始代码在这里处理:如果是树懒dmg=1,否则dmg=2 + dmg := 2 + if nextPlayer.Character == "sloth" { + dmg = 1 + } + e.Logger.Info("Time bomb exploded on player %s! Taking %d damage", nextPlayer.UserID, dmg) + // ApplyDamage(isItemEffect=true 因为它来自道具?) + // 是的,BombTimer 是一个道具。 + e.ApplyDamage(state, nextPlayer, dmg, true) + + if nextPlayer.HP <= 0 { + continue // 死于炸弹,跳过回合 + } + } + } + + // 处理毒药 + if nextPlayer.Poisoned { + nextPlayer.PoisonSteps++ + if nextPlayer.PoisonSteps%2 == 0 { + // 毒药伤害 + e.ApplyDamage(state, nextPlayer, 1, true) + if nextPlayer.HP <= 0 { + continue // 死于毒药,跳过回合 + } + } + } + + // 处理跳过 + if nextPlayer.SkipTurn { + nextPlayer.SkipTurn = false + e.Logger.Info("Player %s skipped turn", nextPlayer.UserID) + continue + } + + // 找到有效玩家 + break + } +} diff --git a/server/logic/grid.go b/server/logic/grid.go new file mode 100644 index 0000000..0602489 --- /dev/null +++ b/server/logic/grid.go @@ -0,0 +1,137 @@ +package logic + +import ( + "math/rand" + "wuziqi-server/core" +) + +// GenerateGrid 创建一个新的随机网格 +func GenerateGrid(gridSize int, bombCount int, itemMin int, itemMax int, enabledItems map[string]bool, itemWeights map[string]int, allItemTypes []string) []*core.GridCell { + totalCells := gridSize * gridSize + grid := make([]*core.GridCell, totalCells) + for i := 0; i < totalCells; i++ { + grid[i] = &core.GridCell{Type: "empty", Revealed: false} + } + + // 放置炸弹 + bombsPlaced := 0 + for bombsPlaced < bombCount && bombsPlaced < totalCells { + idx := rand.Intn(totalCells) + if grid[idx].Type == "empty" { + grid[idx].Type = "bomb" + bombsPlaced++ + } + } + + // 过滤启用的道具 + var pool []string + if len(enabledItems) > 0 { + for _, it := range allItemTypes { + if enabled, ok := enabledItems[it]; ok && enabled { + weight := 10 + if w, ok := itemWeights[it]; ok && w > 0 { + weight = w + } + for i := 0; i < weight; i++ { + pool = append(pool, it) + } + } + } + } + if len(pool) == 0 { + pool = allItemTypes + } + + // 放置道具 + itemCount := rand.Intn(itemMax-itemMin+1) + itemMin + itemsPlaced := 0 + for itemsPlaced < itemCount && (bombsPlaced+itemsPlaced) < totalCells { + idx := rand.Intn(totalCells) + if grid[idx].Type == "empty" { + grid[idx].Type = "item" + grid[idx].ItemID = pool[rand.Intn(len(pool))] + itemsPlaced++ + } + } + + // 计算邻居 + for i := 0; i < totalCells; i++ { + if grid[i].Type == "bomb" { + continue + } + count := 0 + neighbors := GetNeighborIndices(i, gridSize) + for _, neighborIdx := range neighbors { + if grid[neighborIdx].Type == "bomb" { + count++ + } + } + grid[i].NeighborBombs = count + } + + return grid +} + +func GetNeighborIndices(index int, gridSize int) []int { + neighbors := []int{} + row := index / gridSize + col := index % gridSize + + for r := row - 1; r <= row+1; r++ { + for c := col - 1; c <= col+1; c++ { + if (r == row && c == col) || r < 0 || r >= gridSize || c < 0 || c >= gridSize { + continue + } + neighbors = append(neighbors, r*gridSize+c) + } + } + return neighbors +} + +// RevealSafeArea 使用泛洪填充算法揭示连续的空位 +// 标准扫雷规则:揭示边界的数字格子,但不从它们继续扩展 +// 道具和炸弹都不会被揭示,它们阻断泛洪填充 +func RevealSafeArea(state *core.GameState, startIndex int) []int { + revealed := []int{} + visited := make(map[int]bool) + + // 起始格子可能已经被揭示(在调用前被设置), + // 我们需要从它的邻居开始扩展 + startCell := state.Grid[startIndex] + visited[startIndex] = true + + // 初始化队列 + var queue []int + if startCell.NeighborBombs == 0 { + // 起始格子是空白格(无数字),从邻居开始扩展 + queue = GetNeighborIndices(startIndex, state.GridSize) + } + // 如果起始格子有数字,不扩展邻居 + + for len(queue) > 0 { + idx := queue[0] + queue = queue[1:] + + if visited[idx] { + continue + } + visited[idx] = true + + cell := state.Grid[idx] + // 如果已揭示、是炸弹或道具则跳过(道具和炸弹阻断泛洪填充) + if cell.Revealed || cell.Type == "bomb" || cell.Type == "item" { + continue + } + + // 揭示空格子(包括有数字的边界格子) + cell.Revealed = true + revealed = append(revealed, idx) + + // 只有无炸弹邻居的空位才继续扩展 + if cell.NeighborBombs == 0 { + neighbors := GetNeighborIndices(idx, state.GridSize) + queue = append(queue, neighbors...) + } + } + return revealed +} diff --git a/server/logic/logic_test.go b/server/logic/logic_test.go new file mode 100644 index 0000000..98011ee --- /dev/null +++ b/server/logic/logic_test.go @@ -0,0 +1,223 @@ +package logic + +import ( + "testing" + "wuziqi-server/characters" + "wuziqi-server/core" + "wuziqi-server/items" + + "github.com/heroiclabs/nakama-common/runtime" +) + +// --- Mocks --- + +type MockLogger struct { + runtime.Logger +} + +func (m *MockLogger) Info(format string, v ...interface{}) {} +func (m *MockLogger) Warn(format string, v ...interface{}) {} +func (m *MockLogger) Error(format string, v ...interface{}) {} +func (m *MockLogger) Debug(format string, v ...interface{}) {} + +type MockDispatcher struct { + runtime.MatchDispatcher + LastOpCode int64 + LastData []byte +} + +func (m *MockDispatcher) BroadcastMessage(opCode int64, data []byte, presences []runtime.Presence, sender runtime.Presence, reliable bool) error { + m.LastOpCode = opCode + m.LastData = data + return nil +} + +func createTestEngine() (*GameEngine, *core.GameState) { + logger := &MockLogger{} + dispatcher := &MockDispatcher{} + charMgr := characters.NewCharacterManager(nil) + itemMgr := items.NewItemManager() + + engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr) + + // Create simplified state + p1 := &core.Player{UserID: "p1", Username: "P1", HP: 4, MaxHP: 4, Character: "dog", RevealedCells: make(map[int]string)} + p2 := &core.Player{UserID: "p2", Username: "P2", HP: 4, MaxHP: 4, Character: "cat", RevealedCells: make(map[int]string)} + + // Grid + grid := make([]*core.GridCell, 10) + for i := range grid { + grid[i] = &core.GridCell{Type: "empty", Revealed: false} + } + + state := &core.GameState{ + Players: map[string]*core.Player{"p1": p1, "p2": p2}, + Grid: grid, + GridSize: 5, // irrelevant for simplified test + TurnOrder: []string{"p1", "p2"}, + CurrentTurnIndex: 0, + GameStarted: true, + } + + return engine, state +} + +// --- Tests --- + +func TestApplyDamage(t *testing.T) { + engine, state := createTestEngine() + target := state.Players["p1"] + + // 1. Basic Damage + engine.ApplyDamage(state, target, 1, false) + if target.HP != 3 { + t.Errorf("Expected 3 HP, got %d", target.HP) + } + + // 2. Shield Block + target.Shield = true + engine.ApplyDamage(state, target, 10, false) + if target.HP != 3 { + t.Error("Shield should block damage") + } + if target.Shield { + t.Error("Shield should be consumed") + } + + // 3. Curse Damage (Non-Cat) + target.Curse = true + engine.ApplyDamage(state, target, 1, false) + // (3 - 1*2) = 1 + if target.HP != 1 { + t.Errorf("Curse should double dmg, expected 1 HP, got %d", target.HP) + } + if target.Curse { + t.Error("Curse should be consumed") + } + + // 4. Cat Damage Cap + cat := state.Players["p2"] // cat + engine.ApplyDamage(state, cat, 100, false) + if cat.HP != 3 { + t.Errorf("Cat should take 1 dmg (4->3), got %d HP", cat.HP) + } + + // 5. Revive + target.Revive = true + engine.ApplyDamage(state, target, 1000, false) // Kill + if target.HP != 1 { + t.Errorf("Revive should set HP to 1, got %d", target.HP) + } + if target.Revive { + t.Error("Revive should be consumed") + } +} + +func TestAdvanceTurn(t *testing.T) { + engine, state := createTestEngine() + // Order: p1, p2 + // Current: 0 (p1) + + // 1. Simple Advance + engine.AdvanceTurn(state) + if state.CurrentTurnIndex != 1 { // Should be p2 + t.Errorf("Expected turn index 1 (p2), got %d", state.CurrentTurnIndex) + } + + // 2. Wrap Around + engine.AdvanceTurn(state) + if state.CurrentTurnIndex != 0 { // Should be p1 + t.Errorf("Expected turn index 0 (p1), got %d", state.CurrentTurnIndex) + } + + // 3. Skip Turn + state.Players["p2"].SkipTurn = true + // Current p1. Advance -> p2 (skipped) -> p1 + engine.AdvanceTurn(state) + if state.CurrentTurnIndex != 0 { + t.Errorf("Should skip p2 and return to p1, got index %d", state.CurrentTurnIndex) + } + if state.Players["p2"].SkipTurn { + t.Error("SkipTurn flag should be cleared") + } + + // 4. Time Bomb Logic + p2 := state.Players["p2"] + p2.TimeBombTurns = 1 + // Verify p2 takes damage on next turn + // Manually set turn to p1 so next is p2 + state.CurrentTurnIndex = 0 + engine.AdvanceTurn(state) // Move to p2. Bomb explodes. + // Bomb is 2 damage. p2 started with 3 HP (cat took 1 earlier test? No new state here) + // New state created fresh in this test func. p2 is fresh 4 HP Cat. + // Wait, Cat takes 1 dmg from bomb too? + // Logic says: `dmg := 2`. Then ApplyDamage. + // Cat ApplyDamage -> 1. + // So p2 should have 3 HP. + // Wait, BombTimer logic in AdvanceTurn sets dmg=2. + // ApplyDamage calls OnDamageTaken. + // CharacterManager.OnDamageTaken for "cat" returns 1. + // So Cat takes 1. + + if p2.HP != 3 { + t.Errorf("Cat p2 should take 1 dmg from bomb, got HP %d", p2.HP) + } + if state.CurrentTurnIndex != 1 { + t.Error("Should still be p2's turn after bomb (unless died)") + } + + // 5. Poison Logic + p1 := state.Players["p1"] + p1.Poisoned = true + p1.PoisonSteps = 0 + state.CurrentTurnIndex = 1 // Set to p2 so next is p1 + engine.AdvanceTurn(state) // Move to p1 + + // Poison steps increments to 1. No dmg (mod 2 == 0?) + // Code: `PoisonSteps++` (becomes 1). `if PoisonSteps%2 == 0`. + // 1 % 2 != 0. No damage. + + if p1.HP != 4 { // Fresh p1 has 4 + t.Error("Poison should not trigger on step 1") + } + + // Loop back to p1 again + state.CurrentTurnIndex = 1 + engine.AdvanceTurn(state) // Move to p1. Steps becomes 2. 2%2==0. Dmg 1. + if p1.HP != 3 { + t.Errorf("Poison should trigger on step 2, got HP %d", p1.HP) + } +} + +func TestHandleMove(t *testing.T) { + engine, state := createTestEngine() + + // Setup grid + state.Grid[0].Type = "bomb" + state.Grid[1].Type = "empty" + state.Grid[2].Type = "item" + state.Grid[2].ItemID = "medkit" + + p1 := state.Players["p1"] + state.CurrentTurnIndex = 0 // p1 turn + + // 1. Hit Bomb + engine.HandleMove(state, "p1", 0) + // Bomb dmg 2. p1 HP 4->2. + if p1.HP != 2 { + t.Errorf("Hit bomb, expected 2 HP, got %d", p1.HP) + } + // Turn should advance + if state.CurrentTurnIndex != 1 { + t.Error("Turn should advance after bomb") + } + + // 2. Hit Item + state.CurrentTurnIndex = 0 // Force p1 turn again + engine.HandleMove(state, "p1", 2) + // Medkit self heal logic in engine->ItemManager->Strategy + // p1 HP 2->3. + if p1.HP != 3 { + t.Errorf("Item medkit should heal, expected 3 HP, got %d", p1.HP) + } +} diff --git a/server/logic/scenario_test.go b/server/logic/scenario_test.go new file mode 100644 index 0000000..b6cc517 --- /dev/null +++ b/server/logic/scenario_test.go @@ -0,0 +1,468 @@ +package logic + +import ( + "fmt" + "testing" + "wuziqi-server/characters" + "wuziqi-server/core" + "wuziqi-server/items" +) + +// ======================================== +// 游戏场景测试框架 +// ======================================== + +// GameScenario 定义一个游戏测试场景 +type GameScenario struct { + Name string + Players []PlayerSetup + Grid []CellSetup + Actions []GameAction + Checks []ScenarioCheck +} + +// PlayerSetup 玩家配置 +type PlayerSetup struct { + ID string + Character string + HP int + // 可选状态 + Shield bool + Poisoned bool + Curse bool + Revive bool + SkipTurn bool + TimeBomb int +} + +// CellSetup 格子配置 +type CellSetup struct { + Index int + Type string // "empty", "bomb", "item" + ItemID string // 如果是 item + NeighborBombs int +} + +// GameAction 游戏动作 +type GameAction struct { + Type string // "move", "damage", "heal", "use_item" + PlayerID string + Value int // 格子索引或伤害值 + ItemID string // 如果是 use_item +} + +// ScenarioCheck 场景检查点 +type ScenarioCheck struct { + PlayerID string + Field string // "hp", "shield", "poisoned", "revive", "alive" + Expected interface{} + Message string +} + +// RunScenario 运行一个游戏场景 +func RunScenario(t *testing.T, scenario GameScenario) { + t.Run(scenario.Name, func(t *testing.T) { + engine, state := createScenarioState(scenario) + + // 执行动作 + for i, action := range scenario.Actions { + executeAction(t, engine, state, action, i) + } + + // 验证结果 + for _, check := range scenario.Checks { + verifyCheck(t, state, check) + } + }) +} + +func createScenarioState(scenario GameScenario) (*GameEngine, *core.GameState) { + logger := &MockLogger{} + dispatcher := &MockDispatcher{} + charMgr := characters.NewCharacterManager(nil) + itemMgr := items.NewItemManager() + engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr) + + // 创建玩家 + players := make(map[string]*core.Player) + turnOrder := []string{} + for _, ps := range scenario.Players { + hp := ps.HP + if hp == 0 { + hp = charMgr.GetInitialHP(ps.Character, 4) + } + p := &core.Player{ + UserID: ps.ID, + Username: ps.ID, + Character: ps.Character, + HP: hp, + MaxHP: hp, + Shield: ps.Shield, + Poisoned: ps.Poisoned, + Curse: ps.Curse, + Revive: ps.Revive, + SkipTurn: ps.SkipTurn, + TimeBombTurns: ps.TimeBomb, + RevealedCells: make(map[int]string), + } + players[ps.ID] = p + turnOrder = append(turnOrder, ps.ID) + } + + // 创建网格 (默认 10x10) + gridSize := 10 + grid := make([]*core.GridCell, gridSize*gridSize) + for i := range grid { + grid[i] = &core.GridCell{Type: "empty", Revealed: false} + } + // 应用自定义格子设置 + for _, cs := range scenario.Grid { + if cs.Index < len(grid) { + grid[cs.Index].Type = cs.Type + grid[cs.Index].ItemID = cs.ItemID + grid[cs.Index].NeighborBombs = cs.NeighborBombs + } + } + + state := &core.GameState{ + Players: players, + Grid: grid, + GridSize: gridSize, + TurnOrder: turnOrder, + CurrentTurnIndex: 0, + GameStarted: true, + } + + return engine, state +} + +func executeAction(t *testing.T, engine *GameEngine, state *core.GameState, action GameAction, index int) { + player := state.Players[action.PlayerID] + if player == nil && action.PlayerID != "" { + t.Fatalf("Action %d: Player %s not found", index, action.PlayerID) + } + + switch action.Type { + case "move": + // 设置当前回合为该玩家 + for i, uid := range state.TurnOrder { + if uid == action.PlayerID { + state.CurrentTurnIndex = i + break + } + } + engine.HandleMove(state, action.PlayerID, action.Value) + + case "damage": + engine.ApplyDamage(state, player, action.Value, false) + + case "heal": + engine.HealPlayer(player, action.Value) + + case "use_item": + ctx := items.ItemContext{ + Logger: engine.Logger, + Dispatcher: engine.Dispatcher, + Logic: engine, + } + engine.ItemManager.UseItem(state, player, action.ItemID, ctx) + + case "advance_turn": + engine.AdvanceTurn(state) + + case "check_game_over": + engine.CheckGameOver(state) + } +} + +func verifyCheck(t *testing.T, state *core.GameState, check ScenarioCheck) { + player := state.Players[check.PlayerID] + if player == nil { + t.Fatalf("Check failed: Player %s not found", check.PlayerID) + } + + var actual interface{} + switch check.Field { + case "hp": + actual = player.HP + case "shield": + actual = player.Shield + case "poisoned": + actual = player.Poisoned + case "curse": + actual = player.Curse + case "revive": + actual = player.Revive + case "alive": + actual = player.HP > 0 + case "skip_turn": + actual = player.SkipTurn + case "current_turn": + // 获取当前回合玩家的ID + currentUID := state.TurnOrder[state.CurrentTurnIndex] + actual = (currentUID == check.PlayerID) + default: + t.Fatalf("Unknown check field: %s", check.Field) + } + + if actual != check.Expected { + msg := check.Message + if msg == "" { + msg = fmt.Sprintf("Player %s.%s", check.PlayerID, check.Field) + } + t.Errorf("%s: expected %v, got %v", msg, check.Expected, actual) + } +} + +// ======================================== +// 具体测试场景 +// ======================================== + +func TestScenario_SlothBombDamageReduction(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "树懒踩炸弹只受1点伤害", + Players: []PlayerSetup{ + {ID: "sloth1", Character: "sloth", HP: 4}, + {ID: "dog1", Character: "dog", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + {Index: 1, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "sloth1", Value: 0}, // 树懒踩炸弹 + {Type: "move", PlayerID: "dog1", Value: 1}, // 狗踩炸弹 + }, + Checks: []ScenarioCheck{ + {PlayerID: "sloth1", Field: "hp", Expected: 3, Message: "树懒踩炸弹应该只受1点伤害"}, + {PlayerID: "dog1", Field: "hp", Expected: 2, Message: "狗踩炸弹应该受2点伤害"}, + }, + }) +} + +func TestScenario_CatDamageCap(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "猫咪所有伤害强制为1", + Players: []PlayerSetup{ + {ID: "cat1", Character: "cat", HP: 3}, + {ID: "tiger1", Character: "tiger", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "cat1", Value: 0}, // 猫踩炸弹(2伤害->1) + }, + Checks: []ScenarioCheck{ + {PlayerID: "cat1", Field: "hp", Expected: 2, Message: "猫咪受到炸弹伤害应该被限制为1"}, + }, + }) +} + +func TestScenario_CatWithCurse(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "猫咪带诅咒也只受1点伤害", + Players: []PlayerSetup{ + {ID: "cat1", Character: "cat", HP: 3, Curse: true}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "cat1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "cat1", Field: "hp", Expected: 2, Message: "猫咪带诅咒也只受1点伤害"}, + {PlayerID: "cat1", Field: "curse", Expected: false, Message: "诅咒应该被消耗"}, + }, + }) +} + +func TestScenario_ElephantItemRestriction(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "大象无法使用医疗包/好人卡/复活甲", + Players: []PlayerSetup{ + {ID: "elephant1", Character: "elephant", HP: 4}, // 大象5血,先扣1测试 + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "medkit"}, + {Index: 1, Type: "item", ItemID: "skip"}, + {Index: 2, Type: "item", ItemID: "revive"}, + }, + Actions: []GameAction{ + {Type: "damage", PlayerID: "elephant1", Value: 1}, // 先扣1血 + {Type: "move", PlayerID: "elephant1", Value: 0}, // 尝试使用医疗包 + }, + Checks: []ScenarioCheck{ + {PlayerID: "elephant1", Field: "hp", Expected: 3, Message: "大象无法使用医疗包,HP应该保持3"}, + }, + }) +} + +func TestScenario_TigerKnifeAOE(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "老虎在场时飞刀变为全体2点伤害", + Players: []PlayerSetup{ + {ID: "dog1", Character: "dog", HP: 4}, + {ID: "tiger1", Character: "tiger", HP: 4}, + {ID: "cat1", Character: "cat", HP: 3}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "knife"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "dog1", Value: 0}, // 狗使用飞刀 + }, + Checks: []ScenarioCheck{ + // 老虎在场,飞刀变为全体2点伤害 + {PlayerID: "tiger1", Field: "hp", Expected: 2, Message: "老虎应该受到2点伤害"}, + {PlayerID: "cat1", Field: "hp", Expected: 2, Message: "猫咪应该受到1点伤害(猫咪技能)"}, + {PlayerID: "dog1", Field: "hp", Expected: 4, Message: "使用者不受伤害"}, + }, + }) +} + +func TestScenario_HippoCannotPickItems(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "河马无法拾取道具", + Players: []PlayerSetup{ + {ID: "hippo1", Character: "hippo", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "shield"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "hippo1", Value: 0}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "hippo1", Field: "shield", Expected: false, Message: "河马无法获得护盾"}, + }, + }) +} + +func TestScenario_SlothPoisonImmune(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "树懒免疫毒药", + Players: []PlayerSetup{ + {ID: "sloth1", Character: "sloth", HP: 4}, + {ID: "dog1", Character: "dog", HP: 4}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "item", ItemID: "poison"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "dog1", Value: 0}, // 狗使用毒药,目标随机 + }, + Checks: []ScenarioCheck{ + {PlayerID: "sloth1", Field: "poisoned", Expected: false, Message: "树懒应该免疫毒药"}, + }, + }) +} + +func TestScenario_ReviveOnDeath(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "复活甲免疫死亡", + Players: []PlayerSetup{ + {ID: "dog1", Character: "dog", HP: 1, Revive: true}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "dog1", Value: 0}, // 踩炸弹,本应死亡 + }, + Checks: []ScenarioCheck{ + {PlayerID: "dog1", Field: "hp", Expected: 1, Message: "复活甲应该保留1点HP"}, + {PlayerID: "dog1", Field: "revive", Expected: false, Message: "复活甲应该被消耗"}, + {PlayerID: "dog1", Field: "alive", Expected: true, Message: "玩家应该存活"}, + }, + }) +} + +func TestScenario_GameOver(t *testing.T) { + RunScenario(t, GameScenario{ + Name: "只剩一人时游戏结束", + Players: []PlayerSetup{ + {ID: "p1", Character: "dog", HP: 4}, + {ID: "p2", Character: "cat", HP: 1}, + }, + Grid: []CellSetup{ + {Index: 0, Type: "bomb"}, + }, + Actions: []GameAction{ + {Type: "move", PlayerID: "p2", Value: 0}, // p2 踩炸弹死亡 + {Type: "check_game_over"}, + }, + Checks: []ScenarioCheck{ + {PlayerID: "p2", Field: "alive", Expected: false, Message: "p2应该死亡"}, + {PlayerID: "p1", Field: "alive", Expected: true, Message: "p1应该存活"}, + }, + }) +} + +func TestScenario_SafeAreaExpansion(t *testing.T) { + // 测试安全区扩散 + logger := &MockLogger{} + dispatcher := &MockDispatcher{} + charMgr := characters.NewCharacterManager(nil) + itemMgr := items.NewItemManager() + engine := NewGameEngine(logger, dispatcher, charMgr, itemMgr) + + // 创建简单网格测试扩散 + // 布局 (3x3): + // [0:空] [1:空] [2:空] + // [3:空] [4:空] [5:数字1] + // [6:空] [7:数字1] [8:炸弹] + gridSize := 3 + grid := make([]*core.GridCell, gridSize*gridSize) + for i := range grid { + grid[i] = &core.GridCell{Type: "empty", Revealed: false, NeighborBombs: 0} + } + grid[8].Type = "bomb" + // 计算邻居炸弹数 + grid[5].NeighborBombs = 1 // 邻居有 [8:炸弹] + grid[7].NeighborBombs = 1 // 邻居有 [8:炸弹] + grid[4].NeighborBombs = 1 // 邻居有 [8:炸弹] + + player := &core.Player{UserID: "p1", Username: "P1", HP: 4, MaxHP: 4, Character: "dog", RevealedCells: make(map[int]string)} + state := &core.GameState{ + Players: map[string]*core.Player{"p1": player}, + Grid: grid, + GridSize: gridSize, + TurnOrder: []string{"p1"}, + CurrentTurnIndex: 0, + GameStarted: true, + } + + // 点击左上角 (0),应该扩散到所有空白格和边界数字格 + engine.HandleMove(state, "p1", 0) + + // 检查揭示情况 + revealed := []int{} + for i, cell := range grid { + if cell.Revealed { + revealed = append(revealed, i) + } + } + + t.Logf("揭示的格子: %v", revealed) + + // 应该揭示: 0, 1, 2, 3, 4, 5, 6, 7 (除了炸弹8) + if len(revealed) < 5 { + t.Errorf("安全区扩散应该揭示更多格子,只揭示了 %d 个", len(revealed)) + } + + // 炸弹不应该被揭示 + if grid[8].Revealed { + t.Error("炸弹不应该被揭示") + } +} + +// ======================================== +// 运行所有场景测试 +// ======================================== + +func TestAllScenarios(t *testing.T) { + t.Log("运行所有游戏场景测试...") + // 各个场景测试函数会被 Go test 自动发现和运行 +} diff --git a/server/main.go b/server/main.go index 75a6525..0f7f2b5 100644 --- a/server/main.go +++ b/server/main.go @@ -1,1180 +1,46 @@ package main import ( - "bytes" "context" "database/sql" - "encoding/json" - "fmt" - "io/ioutil" "math/rand" - "net/http" "os" "strings" "time" + "wuziqi-server/config" + "wuziqi-server/handlers" + "github.com/heroiclabs/nakama-common/runtime" ) -// --- Constants & Enums --- - -const ( - OpCodeGameStart = 1 - OpCodeUpdateState = 2 - OpCodeMove = 3 - OpCodeGameEvent = 5 // Special game events (item use, character ability) - OpCodeGameOver = 6 - OpCodeGetState = 100 // New opcode for requesting current state - - MaxPlayers = 2 // Default value, will be overridden by config -) - -var ( - BackendBaseURL = "http://host.docker.internal:9991/api/internal" // Default - InternalAPIKey = "bindbox-internal-secret-2024" // Must match backend -) - -var httpClient = &http.Client{ - Timeout: 5 * time.Second, -} - -// Helper function to make authenticated internal API requests -func makeInternalRequest(method, url string, body []byte) (*http.Response, error) { - req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Internal-Key", InternalAPIKey) - return httpClient.Do(req) -} - -type MinesweeperConfig struct { - GridSize int `json:"grid_size"` - BombCount int `json:"bomb_count"` - ItemMin int `json:"item_min"` - ItemMax int `json:"item_max"` - HPInit int `json:"hp_init"` - MatchPlayerCount int `json:"match_player_count"` // Required players to start match - EnabledItems map[string]bool `json:"enabled_items"` - ItemWeights map[string]int `json:"item_weights"` -} - -func getMinesweeperConfig(logger runtime.Logger) *MinesweeperConfig { - url := BackendBaseURL + "/game/minesweeper/config" - logger.Info("Fetching minesweeper config from: %s", url) - - resp, err := makeInternalRequest("GET", url, nil) - if err != nil { - logger.Error("Network error fetching config (check BackendBaseURL): %v", err) - return nil - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - logger.Error("Backend config API returned status: %d (URL: %s)", resp.StatusCode, url) - return nil - } - - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - logger.Error("Failed to read config response body: %v", err) - return nil - } - - var config MinesweeperConfig - if err := json.Unmarshal(body, &config); err != nil { - logger.Error("Failed to parse minesweeper config: %v. Raw body: %s", err, string(body)) - return nil - } - - return &config -} - -type VerifyTicketResponse struct { - Valid bool `json:"valid"` - UserID string `json:"user_id"` - RemainingTimes int `json:"remaining_times"` -} - -type SettleGameResponse struct { - Success bool `json:"success"` - Reward string `json:"reward"` -} - -// GameTokenInfo contains validated token information from backend -type GameTokenInfo struct { - Valid bool `json:"valid"` - UserID int64 `json:"user_id"` - Username string `json:"username"` - Avatar string `json:"avatar"` - GameType string `json:"game_type"` - Ticket string `json:"ticket"` - Error string `json:"error"` -} - -// validateGameToken validates a game token with the backend and returns user info -func validateGameToken(logger runtime.Logger, gameToken string) *GameTokenInfo { - logger.Info("Validating game token with backend") - - reqBody, _ := json.Marshal(map[string]string{ - "game_token": gameToken, - }) - - resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/validate-token", reqBody) - if err != nil { - logger.Error("Failed to call backend validate-token API: %v", err) - return nil - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - logger.Error("Backend validate-token returned non-200 status: %d", resp.StatusCode) - return nil - } - - body, _ := ioutil.ReadAll(resp.Body) - var result GameTokenInfo - if err := json.Unmarshal(body, &result); err != nil { - logger.Error("Failed to parse validate-token response: %v", err) - return nil - } - - if !result.Valid { - logger.Warn("Game token invalid: %s", result.Error) - return nil - } - - return &result -} - -func verifyTicketWithBackend(logger runtime.Logger, userID string, ticket string) bool { - logger.Info("Verifying ticket with backend: %s for user %s", ticket, userID) - - reqBody, _ := json.Marshal(map[string]string{ - "user_id": userID, - "ticket": ticket, - }) - - resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/verify", reqBody) - if err != nil { - logger.Error("Failed to call backend verify API: %v", err) - return false // Fail safe - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - logger.Error("Backend returned non-200 status: %d", resp.StatusCode) - return false - } - - body, _ := ioutil.ReadAll(resp.Body) - var result VerifyTicketResponse - if err := json.Unmarshal(body, &result); err != nil { - logger.Error("Failed to parse backend response: %v", err) - return false - } - - return result.Valid -} - -// settleGameWithBackend settles the game with the backend using the real user ID -func settleGameWithBackend(logger runtime.Logger, realUserID int64, ticket, matchID string, win bool, score int) { - logger.Info("Settling game with backend for realUserID %d (Win: %v)", realUserID, win) - - reqBody, _ := json.Marshal(map[string]interface{}{ - "user_id": fmt.Sprintf("%d", realUserID), // Backend expects string - "ticket": ticket, - "match_id": matchID, - "win": win, - "score": score, - }) - - // Async call to not block game loop too much, or use goroutine - go func() { - resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/settle", reqBody) - if err != nil { - logger.Error("Failed to call backend settle API: %v", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - logger.Error("Backend settle returned non-200: %d", resp.StatusCode) - } else { - logger.Info("Game settled successfully with backend") - } - }() -} - -var ( - ItemTypes = []string{ - "medkit", "bomb_timer", "poison", "shield", "skip", - "magnifier", "knife", "revive", "lightning", "chest", "curse", - } - CharacterTypes = []string{ - "elephant", "cat", "dog", "monkey", "chicken", "sloth", "hippo", "tiger", - } - - CharacterData = map[string]struct { - MaxHP int - Avatar string - Desc string - }{ - "elephant": {MaxHP: 5, Avatar: "🐘", Desc: "High HP, can't use some items"}, - "cat": {MaxHP: 3, Avatar: "🐱", Desc: "Max dmg taken is 1"}, - "dog": {MaxHP: 4, Avatar: "🐶", Desc: "Periodic magnifier"}, - "monkey": {MaxHP: 4, Avatar: "🐒", Desc: "Get banana (heal) chance"}, - "chicken": {MaxHP: 4, Avatar: "🐔", Desc: "Get item on dmg chance"}, - "sloth": {MaxHP: 4, Avatar: "🦥", Desc: "Immune poison, bomb dmg reduced"}, - "hippo": {MaxHP: 4, Avatar: "🦛", Desc: "Cant pick items, resist death chance"}, - "tiger": {MaxHP: 4, Avatar: "🐯", Desc: "Stronger knife"}, - } -) - -// --- Structs --- - -type GridCell struct { - Type string `json:"type"` // "empty" | "bomb" | "item" - ItemID string `json:"itemId,omitempty"` - Revealed bool `json:"revealed"` - NeighborBombs int `json:"neighborBombs"` // Count of adjacent bombs -} - -type Player struct { - UserID string `json:"userId"` // Nakama user ID - RealUserID int64 `json:"realUserId"` // Backend user ID (for settlements) - SessionID string `json:"sessionId"` - Username string `json:"username"` - Avatar string `json:"avatar"` - HP int `json:"hp"` - MaxHP int `json:"maxHp"` - Status []string `json:"status"` // Visual status tags - Character string `json:"character"` - Ticket string `json:"ticket"` // Store the ticket used to join - - // Status Flags - Shield bool `json:"shield"` - SkipTurn bool `json:"skipTurn"` - Poisoned bool `json:"poisoned"` - PoisonSteps int `json:"poisonSteps"` // Steps taken since poisoned - Revive bool `json:"revive"` - Curse bool `json:"curse"` - TimeBombTurns int `json:"timeBombTurns"` // Countdown for time bomb (0 = no bomb) - - // Character ability usage counters (for limits) - MonkeyBananaCount int `json:"monkeyBananaCount"` // Max 2 per game - ChickenItemCount int `json:"chickenItemCount"` // Max 2 per game - HippoDeathImmune bool `json:"hippoDeathImmune"` // Used once = true - - // Magnifier reveals (cell index -> cell type) - RevealedCells map[int]string `json:"revealedCells"` -} - -type GameState struct { - Players map[string]*Player `json:"players"` - Grid []*GridCell `json:"grid"` - GridSize int `json:"gridSize"` // Side length of the square grid - TurnOrder []string `json:"turnOrder"` - CurrentTurnIndex int `json:"currentTurnIndex"` - Round int `json:"round"` - GlobalTurnCount int `json:"globalTurnCount"` // Total turns taken (for Dog ability) - WinnerID string `json:"winnerId"` - GameStarted bool `json:"gameStarted"` - LastMoveTimestamp int64 `json:"lastMoveTimestamp"` // Unix timestamp in seconds -} - -type MoveMessage struct { - Index int `json:"index"` -} - -type GetStateMessage struct { - Action string `json:"action"` -} - -type MatchState struct { - State *GameState - HPInit int - MatchPlayerCount int // Dynamic player count for matching - ValidatedPlayers map[string]*GameTokenInfo // Cache: NakamaUserID -> validated token info -} - -// GameEvent represents a special event to be displayed in client logs -type GameEvent struct { - Type string `json:"type"` // "ability", "item", "damage", "heal", "status" - PlayerID string `json:"playerId"` - PlayerName string `json:"playerName"` - TargetID string `json:"targetId,omitempty"` - TargetName string `json:"targetName,omitempty"` - ItemID string `json:"itemId,omitempty"` - Value int `json:"value,omitempty"` - Message string `json:"message"` -} - -func broadcastEvent(dispatcher runtime.MatchDispatcher, event GameEvent) { - data, _ := json.Marshal(event) - dispatcher.BroadcastMessage(OpCodeGameEvent, data, nil, nil, true) -} - -// --- Match Handler Methods --- -type MatchHandler struct{} - -func (m *MatchHandler) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) { - logger.Error("MatchInit called (Go) - Dynamic Config Version") - - // Fetch dynamic config - apiConfig := getMinesweeperConfig(logger) - - // Set defaults if fetch fails - gridSizeSide := 10 - totalCells := 100 - bombCount := 30 - itemCountMin := 5 - itemCountMax := 10 - hpInit := 4 - - if apiConfig != nil { - if apiConfig.GridSize > 0 && apiConfig.GridSize <= 30 { - gridSizeSide = apiConfig.GridSize - } - totalCells = gridSizeSide * gridSizeSide - if apiConfig.BombCount > 0 && apiConfig.BombCount < totalCells { - bombCount = apiConfig.BombCount - } - if apiConfig.ItemMax >= apiConfig.ItemMin && apiConfig.ItemMin >= 0 { - itemCountMin = apiConfig.ItemMin - itemCountMax = apiConfig.ItemMax - } - if apiConfig.HPInit > 0 { - hpInit = apiConfig.HPInit - } - logger.Info("Using dynamic config: %+v (Final: Grid=%d, Bombs=%d, HP=%d)", apiConfig, gridSizeSide, bombCount, hpInit) - } - - // Dynamic match player count - matchPlayerCount := MaxPlayers // Default - if apiConfig != nil && apiConfig.MatchPlayerCount >= 2 && apiConfig.MatchPlayerCount <= 10 { - matchPlayerCount = apiConfig.MatchPlayerCount - logger.Info("Using dynamic match_player_count: %d", matchPlayerCount) - } - - // Generate Grid - grid := make([]*GridCell, totalCells) - for i := 0; i < totalCells; i++ { - grid[i] = &GridCell{Type: "empty", Revealed: false} - } - - // Place Bombs - bombsPlaced := 0 - for bombsPlaced < bombCount && bombsPlaced < totalCells { - idx := rand.Intn(totalCells) - if grid[idx].Type == "empty" { - grid[idx].Type = "bomb" - bombsPlaced++ - } - } - - // Filter enabled items and calculate weights - var pool []string - if apiConfig != nil && len(apiConfig.EnabledItems) > 0 { - for _, it := range ItemTypes { - if enabled, ok := apiConfig.EnabledItems[it]; ok && enabled { - weight := 10 - if w, ok := apiConfig.ItemWeights[it]; ok && w > 0 { - weight = w - } - for i := 0; i < weight; i++ { - pool = append(pool, it) - } - } - } - } - - // Fallback pool if empty - if len(pool) == 0 { - pool = ItemTypes - } - - // Place Items - itemCount := rand.Intn(itemCountMax-itemCountMin+1) + itemCountMin - itemsPlaced := 0 - for itemsPlaced < itemCount && (bombsPlaced+itemsPlaced) < totalCells { - idx := rand.Intn(totalCells) - if grid[idx].Type == "empty" { - grid[idx].Type = "item" - grid[idx].ItemID = pool[rand.Intn(len(pool))] - itemsPlaced++ - } - } - - // Calculate Neighbor Bombs for all cells - for i := 0; i < totalCells; i++ { - if grid[i].Type == "bomb" { - continue - } - - count := 0 - row := i / gridSizeSide - col := i % gridSizeSide - - // Check all 8 neighbors - for r := row - 1; r <= row+1; r++ { - for c := col - 1; c <= col+1; c++ { - // Skip valid check - if r >= 0 && r < gridSizeSide && c >= 0 && c < gridSizeSide { - // Skip self - if r == row && c == col { - continue - } - neighborIdx := r*gridSizeSide + c - if grid[neighborIdx].Type == "bomb" { - count++ - } - } - } - } - grid[i].NeighborBombs = count - } - - state := &GameState{ - Players: make(map[string]*Player), - Grid: grid, - GridSize: gridSizeSide, - TurnOrder: make([]string, 0), - CurrentTurnIndex: 0, - Round: 1, - WinnerID: "", - GameStarted: false, - } - - // Store hpInit in params or just use local, but MatchJoin needs it - // We can store it in MatchState for future joins - tickRate := 10 - label := "Animal Minesweeper" - - return &MatchState{ - State: state, - HPInit: hpInit, - MatchPlayerCount: matchPlayerCount, - ValidatedPlayers: make(map[string]*GameTokenInfo), - }, tickRate, label -} - -func (m *MatchHandler) MatchJoinAttempt(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string) (interface{}, bool, string) { - matchState := state.(*MatchState) - gameState := matchState.State - - // 1. Validate GameToken (REQUIRED - prevents free play and spoofing) - gameToken, ok := metadata["game_token"] - if !ok || gameToken == "" { - logger.Warn("MatchJoinAttempt: No game_token provided for user %s, rejecting", presence.GetUserId()) - return state, false, "Game token required to join" - } - - // Validate token with backend and get real user info - tokenInfo := validateGameToken(logger, gameToken) - if tokenInfo == nil { - logger.Warn("MatchJoinAttempt: Invalid game_token for user %s", presence.GetUserId()) - return state, false, "Invalid or expired game token" - } - - // Cache the validated info for use in MatchJoin - matchState.ValidatedPlayers[presence.GetUserId()] = tokenInfo - logger.Info("MatchJoinAttempt: Validated user %s (RealUserID: %d, Username: %s)", - presence.GetUserId(), tokenInfo.UserID, tokenInfo.Username) - - if gameState.GameStarted { - return state, false, "Game already started" - } - if len(gameState.Players) >= matchState.MatchPlayerCount { - return state, false, "Match full" - } - - return state, true, "" -} - -func (m *MatchHandler) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} { - matchState := state.(*MatchState) - gameState := matchState.State - - for _, presence := range presences { - if _, exists := gameState.Players[presence.GetUserId()]; exists { - continue - } - - // Get validated token info from cache - tokenInfo := matchState.ValidatedPlayers[presence.GetUserId()] - if tokenInfo == nil { - // This should not happen if MatchJoinAttempt worked correctly - logger.Error("MatchJoin: No cached token info for user %s, rejecting", presence.GetUserId()) - continue - } - - character := CharacterTypes[rand.Intn(len(CharacterTypes))] - charData := CharacterData[character] - initialHP := charData.MaxHP - if matchState.HPInit > 0 { - initialHP = matchState.HPInit - } - - // Use real user info from validated token - username := tokenInfo.Username - if username == "" { - username = presence.GetUsername() // Fallback to Nakama username - } - - player := &Player{ - UserID: presence.GetUserId(), - RealUserID: tokenInfo.UserID, // Backend user ID for settlements - SessionID: presence.GetSessionId(), - Username: username, - Avatar: charData.Avatar, - HP: initialHP, - MaxHP: initialHP, - Status: make([]string, 0), - Character: character, - RevealedCells: make(map[int]string), - Ticket: tokenInfo.Ticket, - } - - gameState.Players[presence.GetUserId()] = player - gameState.TurnOrder = append(gameState.TurnOrder, presence.GetUserId()) - logger.Info("Player joined: %s (RealUserID: %d, Username: %s)", - presence.GetUserId(), tokenInfo.UserID, username) - } - - // Check if full to start game - if len(gameState.Players) >= matchState.MatchPlayerCount && !gameState.GameStarted { - gameState.GameStarted = true - gameState.LastMoveTimestamp = time.Now().Unix() - logger.Info("Game Started! TurnOrder: %v, First player: %s", gameState.TurnOrder, gameState.TurnOrder[0]) - - // Consume game tickets for all players on match success - for _, player := range gameState.Players { - go func(p *Player) { - consumeReqBody, _ := json.Marshal(map[string]string{ - "user_id": fmt.Sprintf("%d", p.RealUserID), - "game_code": "minesweeper", - "ticket": p.Ticket, - }) - resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/consume-ticket", consumeReqBody) - if err != nil { - logger.Error("Failed to consume ticket for user %d: %v", p.RealUserID, err) - return - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - logger.Error("Backend consume-ticket returned non-200 for user %d: %d", p.RealUserID, resp.StatusCode) - } else { - logger.Info("Successfully consumed ticket for user %d on match success", p.RealUserID) - } - }(player) - } - - data, _ := json.Marshal(gameState) - dispatcher.BroadcastMessage(OpCodeGameStart, data, nil, nil, true) - } else { - logger.Info("Player count: %d. Broadcasting UPDATE_STATE", len(gameState.Players)) - data, _ := json.Marshal(gameState) - dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) - } - - return matchState -} - -func (m *MatchHandler) MatchLeave(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} { - matchState := state.(*MatchState) - gameState := matchState.State - - for _, presence := range presences { - delete(gameState.Players, presence.GetUserId()) - - // Remove from turn order - for i, uid := range gameState.TurnOrder { - if uid == presence.GetUserId() { - // Remove element - gameState.TurnOrder = append(gameState.TurnOrder[:i], gameState.TurnOrder[i+1:]...) - - // Adjust turn index - if i < gameState.CurrentTurnIndex { - gameState.CurrentTurnIndex-- - } - if len(gameState.TurnOrder) > 0 && gameState.CurrentTurnIndex >= len(gameState.TurnOrder) { - gameState.CurrentTurnIndex = 0 - } - break - } - } - } - - // Broadcast update if game is running - if gameState.GameStarted && len(gameState.Players) > 0 { - data, _ := json.Marshal(gameState) - dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) - } - - // End game if less than 2 players (always need at least 2 to continue) - if gameState.GameStarted && len(gameState.Players) < 2 { - winnerID := "" - if len(gameState.Players) == 1 { - for uid := range gameState.Players { - winnerID = uid - break - } - } - gameState.WinnerID = winnerID - - endData, _ := json.Marshal(map[string]string{"winnerId": winnerID}) - dispatcher.BroadcastMessage(OpCodeGameOver, endData, nil, nil, true) - return nil // End match - } - - return matchState -} - -func (m *MatchHandler) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} { - matchState := state.(*MatchState) - gameState := matchState.State - - // Only log when there are messages to process - if len(messages) > 0 { - logger.Info("MatchLoop processing %d messages", len(messages)) - } - - for _, message := range messages { - logger.Info("Processing message with op_code: %d, sender: %s", message.GetOpCode(), message.GetUserId()) - switch message.GetOpCode() { - case OpCodeMove: - if !gameState.GameStarted { - logger.Info("MatchLoop: Game not started ignoring move. GameStarted=%v", gameState.GameStarted) - continue // Skip move messages if game hasn't started - } - logger.Info("MatchLoop: Processing OpCodeMove") - var move MoveMessage - if err := json.Unmarshal(message.GetData(), &move); err != nil { - logger.Error("Failed to parse move data: %v", err) - continue - } - handleMove(gameState, message.GetUserId(), move.Index, logger, dispatcher) - - case OpCodeGetState: - // Handle get state request - send current state to requesting player - logger.Debug("Sending current game state to player: %s", message.GetUserId()) - data, _ := json.Marshal(gameState) - dispatcher.BroadcastMessage(OpCodeUpdateState, data, []runtime.Presence{message}, nil, true) - } - } - - // Inactivity Timeout Check (15 seconds) - if gameState.GameStarted && gameState.WinnerID == "" { - now := time.Now().Unix() - if now-gameState.LastMoveTimestamp >= 15 { - currentUserID := gameState.TurnOrder[gameState.CurrentTurnIndex] - logger.Info("Inactivity timeout for player %s (15s). Deducting 1 HP and advancing turn.", currentUserID) - - player := gameState.Players[currentUserID] - if player != nil { - // Deduct 1 HP - applyDamage(gameState, player, 1) - - // Advance Turn - advanceTurn(gameState, logger) - - // Update last move timestamp - gameState.LastMoveTimestamp = time.Now().Unix() - - // Check Game Over - if checkGameOver(gameState, dispatcher, logger) { - return matchState - } - - // Broadcast update - data, _ := json.Marshal(gameState) - dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) - } - } - } - - return matchState -} - -func (m *MatchHandler) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} { - return state -} - -func (m *MatchHandler) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) { - return state, data -} - -// --- Helper Functions --- - -func handleMove(state *GameState, userID string, cellIndex int, logger runtime.Logger, dispatcher runtime.MatchDispatcher) { - logger.Info("handleMove: userID=%s, cellIndex=%d", userID, cellIndex) - - if len(state.TurnOrder) == 0 { - return - } - - currentUserID := state.TurnOrder[state.CurrentTurnIndex] - if userID != currentUserID { - logger.Info("handleMove: Not your turn. Expected=%s, Got=%s", currentUserID, userID) - return - } - - if cellIndex < 0 || cellIndex >= len(state.Grid) { - return - } - - cell := state.Grid[cellIndex] - if cell.Revealed { - return - } - - // Reveal - cell.Revealed = true - player := state.Players[userID] - - // Increment global turn counter - state.GlobalTurnCount++ - - // 狗狗天赋: 定期触发放大镜效果 - // 4人局每6回合触发一次,6人局每9回合触发一次,自动透视一个随机未翻开的格子 - if player.Character == "dog" { - interval := 6 - if len(state.Players) >= 6 { - interval = 9 - } - if state.GlobalTurnCount%interval == 0 { - // 随机选择一个未翻开的格子透视给玩家 - for i := 0; i < 100; i++ { - idx := rand.Intn(len(state.Grid)) - if !state.Grid[idx].Revealed { - logger.Info("Dog %s activates magnifier! Cell %d is %s", player.UserID, idx, state.Grid[idx].Type) - broadcastEvent(dispatcher, GameEvent{ - Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, - Message: "🐶 狗狗触发嗅觉天赋,获得了一个格子的信息!", - }) - break - } - } - } - } - - // 猴子天赋: 香蕉概率回复 - // 每次行动有15%概率获得香蕉(回复1点HP),每局游戏最多生效2次 - if player.Character == "monkey" && player.MonkeyBananaCount < 2 && rand.Float32() < 0.15 { - healPlayer(player, 1) - player.MonkeyBananaCount++ - logger.Info("Monkey %s found a banana! (%d/2)", player.UserID, player.MonkeyBananaCount) - broadcastEvent(dispatcher, GameEvent{ - Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, - Value: 1, Message: "🍌 猴子发现了香蕉,回复1点血量!", - }) - } - - // Handle Cell Content - if cell.Type == "bomb" { - dmg := 2 - if player.Character == "sloth" { - dmg = 1 - } - logger.Info("Player %s stepped on bomb (dmg=%d)", player.UserID, dmg) - broadcastEvent(dispatcher, GameEvent{ - Type: "damage", PlayerID: player.UserID, PlayerName: player.Username, - Value: dmg, Message: fmt.Sprintf("💣 踩到炸弹,受到%d点伤害!", dmg), - }) - applyDamage(state, player, dmg) - - } else if cell.Type == "item" { - if player.Character == "hippo" { - logger.Info("Hippo %s cannot pick up items", player.UserID) - broadcastEvent(dispatcher, GameEvent{ - Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: cell.ItemID, Message: "🦛 河马无法拾取道具!", - }) - } else { - resolveItem(state, player, cell.ItemID, logger, dispatcher) - } - } - - // Check Game Over - if checkGameOver(state, dispatcher, logger) { - return - } - - // Advance Turn - advanceTurn(state, logger) - - // Update last move timestamp - state.LastMoveTimestamp = time.Now().Unix() - - // Double check Game Over (poison might have killed someone) - if checkGameOver(state, dispatcher, logger) { - return - } - - // Broadcast - data, _ := json.Marshal(state) - dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) -} - -// resolveItem 处理道具效果 -// 道具被拾取后自动使用,根据不同道具类型触发对应效果 -func resolveItem(state *GameState, player *Player, item string, logger runtime.Logger, dispatcher runtime.MatchDispatcher) { - logger.Info("Player %s used item: %s", player.UserID, item) - - // 大象角色限制: 无法使用医疗包、好人卡、复活甲 - if player.Character == "elephant" && (item == "medkit" || item == "skip" || item == "revive") { - logger.Info("Elephant refused item %s", item) - broadcastEvent(dispatcher, GameEvent{ - Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Message: "🐘 大象无法使用该道具!", - }) - return - } - - switch item { - case "medkit": - player.Poisoned = false - player.PoisonSteps = 0 - healPlayer(player, 1) - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Value: 1, Message: "💊 使用医疗包,回复1血并解除中毒!", - }) - case "bomb_timer": - player.TimeBombTurns = 3 - logger.Info("Player %s has a time bomb! Explodes in 3 turns", player.UserID) - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Value: 3, Message: "⏰ 定时炸弹启动,3回合后爆炸!", - }) - case "poison": - target := getRandomAliveTarget(state, player.UserID) - if target != nil { - if target.Character == "sloth" { - logger.Info("Sloth resisted poison") - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - TargetID: target.UserID, TargetName: target.Username, - ItemID: item, Message: "🦥 树懒免疫了毒药!", - }) - } else { - target.Poisoned = true - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - TargetID: target.UserID, TargetName: target.Username, - ItemID: item, Message: fmt.Sprintf("☠️ %s 中毒了!", target.Username), - }) - } - } - case "shield": - player.Shield = true - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Message: "🛡️ 获得护盾,可抵挡一次伤害!", - }) - case "skip": - player.SkipTurn = true - player.Shield = true - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Message: "⏭️ 好人卡:跳过回合并获得护盾!", - }) - case "magnifier": - for i := 0; i < 100; i++ { - idx := rand.Intn(len(state.Grid)) - if !state.Grid[idx].Revealed { - cellType := state.Grid[idx].Type - if state.Grid[idx].Type == "item" { - cellType = state.Grid[idx].ItemID - } - player.RevealedCells[idx] = cellType - logger.Info("Magnifier: Player %s can now see cell %d (%s)", player.UserID, idx, cellType) - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Message: "🔍 放大镜:透视了一个隐藏格子!", - }) - break - } - } - case "knife": - dmg := 1 - isAOE := false - if player.Character == "tiger" { - dmg = 2 - isAOE = true - } - - if isAOE { - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Value: dmg, Message: fmt.Sprintf("🐯🔪 老虎的飞刀对所有敌人造成%d点伤害!", dmg), - }) - for _, p := range state.Players { - if p.UserID != player.UserID && p.HP > 0 { - applyDamage(state, p, dmg) - } - } - } else { - target := getRandomAliveTarget(state, player.UserID) - if target != nil { - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - TargetID: target.UserID, TargetName: target.Username, - ItemID: item, Value: dmg, Message: fmt.Sprintf("🔪 飞刀命中 %s,造成%d点伤害!", target.Username, dmg), - }) - applyDamage(state, target, dmg) - } - } - case "revive": - player.Revive = true - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Message: "💖 获得复活甲,可免疫一次死亡!", - }) - case "lightning": - broadcastEvent(dispatcher, GameEvent{ - Type: "item", PlayerID: player.UserID, PlayerName: player.Username, - ItemID: item, Value: 1, Message: "⚡ 闪电对所有玩家造成1点伤害!", - }) - for _, p := range state.Players { - applyDamage(state, p, 1) - } - case "chest": - // Meta reward, logic ignored - case "curse": - player.Curse = true - } -} - -func applyDamage(state *GameState, target *Player, amount int) { - if target.HP <= 0 { - return - } - - // 护盾优先抵挡伤害(消耗护盾,不受伤) - if target.Shield { - target.Shield = false - return // 完全格挡 - } - - // 猫咪天赋: 所有伤害强制为1点(诅咒加成也无效) - if target.Character == "cat" { - amount = 1 - target.Curse = false // 诅咒被消耗但不生效 - } else if target.Curse { - // 非猫咪角色: 诅咒使伤害翻倍 - amount *= 2 - target.Curse = false - } - - // Apply damage - target.HP -= amount - - // 坤坤天赋: 受伤时有概率获得道具 - // 8%概率获得好人卡/护盾/放大镜之一,每局最多触发2次 - if target.Character == "chicken" && target.HP > 0 && target.ChickenItemCount < 2 { - if rand.Float32() < 0.08 { - target.ChickenItemCount++ - // 随机获得: 好人卡(skip), 护盾(shield), 放大镜(magnifier) - items := []string{"skip", "shield", "magnifier"} - item := items[rand.Intn(len(items))] - switch item { - case "skip": - target.SkipTurn = true - target.Shield = true // 好人卡附带护盾效果 - case "shield": - target.Shield = true - case "magnifier": - // 放大镜效果在其他地方处理 - } - } - } - - // Death Check - if target.HP <= 0 { - if target.Revive { - target.Revive = false - target.HP = 1 - } else if target.Character == "hippo" && !target.HippoDeathImmune { - // 55% chance to survive death (once per game) - if rand.Float32() < 0.55 { - target.HP = 1 - target.HippoDeathImmune = true // Mark as used - } - } - } - - if target.HP < 0 { - target.HP = 0 - } -} - -func healPlayer(p *Player, amount int) { - if p.HP < p.MaxHP { - p.HP += amount - if p.HP > p.MaxHP { - p.HP = p.MaxHP - } - } -} - -func getRandomAliveTarget(state *GameState, excludeID string) *Player { - candidates := []*Player{} - for _, p := range state.Players { - if p.UserID != excludeID && p.HP > 0 { - candidates = append(candidates, p) - } - } - if len(candidates) == 0 { - return nil - } - - return candidates[rand.Intn(len(candidates))] -} - -func advanceTurn(state *GameState, logger runtime.Logger) { - scanCount := 0 - - for { - state.CurrentTurnIndex = (state.CurrentTurnIndex + 1) % len(state.TurnOrder) - scanCount++ - - // Prevent infinite loop if everyone skips/is dead - if scanCount > len(state.TurnOrder)*2 { - break - } - - nextUID := state.TurnOrder[state.CurrentTurnIndex] - nextPlayer := state.Players[nextUID] - - if nextPlayer.HP <= 0 { - continue - } - - // Handle Time Bomb countdown - if nextPlayer.TimeBombTurns > 0 { - nextPlayer.TimeBombTurns-- - if nextPlayer.TimeBombTurns == 0 { - // BOOM! Time bomb explodes - dmg := 2 - if nextPlayer.Character == "sloth" { - dmg = 1 // Sloth takes reduced bomb damage - } - logger.Info("Time bomb exploded on player %s! Taking %d damage", nextPlayer.UserID, dmg) - applyDamage(state, nextPlayer, dmg) - if nextPlayer.HP <= 0 { - continue // Died from bomb, skip turn - } - } - } - - // Handle Poison - if nextPlayer.Poisoned { - nextPlayer.PoisonSteps++ - if nextPlayer.PoisonSteps%2 == 0 { - applyDamage(state, nextPlayer, 1) - if nextPlayer.HP <= 0 { - continue // Died from poison, skip turn - } - } - } - - // Handle Skip - if nextPlayer.SkipTurn { - nextPlayer.SkipTurn = false - logger.Info("Player %s skipped turn", nextPlayer.UserID) - continue - } - - // Found valid player - break - } - - // Game Over check should happen outside -} - -func checkGameOver(state *GameState, dispatcher runtime.MatchDispatcher, logger runtime.Logger) bool { - alive := []string{} - for _, p := range state.Players { - if p.HP > 0 { - alive = append(alive, p.UserID) - } - } - - if len(alive) <= 1 { - winnerID := "" - if len(alive) == 1 { - winnerID = alive[0] - } - state.WinnerID = winnerID - state.GameStarted = false - - // 2. Settle Game with Backend using RealUserID - if winnerID != "" { - winnerPlayer := state.Players[winnerID] - if winnerPlayer != nil && winnerPlayer.RealUserID > 0 { - settleGameWithBackend(logger, winnerPlayer.RealUserID, winnerPlayer.Ticket, "", true, 100) - } else { - logger.Error("Winner player has no RealUserID, cannot settle") - } - } - - endData, _ := json.Marshal(map[string]interface{}{ - "winnerId": winnerID, - "gameState": state, - }) - dispatcher.BroadcastMessage(OpCodeGameOver, endData, nil, nil, true) - return true - } - return false -} - -// Presence helper for broadcast -type UserIDPresence struct { - UserID string - SessionID string - Username string -} - -func (p *UserIDPresence) GetUserId() string { return p.UserID } -func (p *UserIDPresence) GetSessionId() string { return p.SessionID } -func (p *UserIDPresence) GetNodeId() string { return "" } -func (p *UserIDPresence) GetHidden() bool { return false } -func (p *UserIDPresence) GetPersistence() bool { return false } -func (p *UserIDPresence) GetUsername() string { return p.Username } -func (p *UserIDPresence) GetStatus() string { return "" } -func (p *UserIDPresence) GetReason() runtime.PresenceReason { return runtime.PresenceReasonUnknown } - -// --- Init Module --- - func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error { - logger.Error("Nakama Go Module Loaded - Dynamic Config Version") + logger.Info("Nakama Go Module Loaded - Refactored Version") - // Seed random + // 随机数种子 rand.Seed(time.Now().UnixNano()) - // Initialize Backend URL from Env if exists + // 如果存在,从环境变量初始化后端 URL envURL := os.Getenv("MINESWEEPER_BACKEND_URL") if envURL != "" { - BackendBaseURL = strings.TrimSuffix(envURL, "/") - logger.Info("Setting BackendBaseURL from environment: %s", BackendBaseURL) + newURL := strings.TrimSuffix(envURL, "/") + config.SetBackendBaseURL(newURL) + logger.Info("Setting BackendBaseURL from environment: %s", newURL) } + // 注册比赛处理器 if err := initializer.RegisterMatch("animal_minesweeper", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (runtime.Match, error) { - logger.Error("Creating new MatchHandler") - return &MatchHandler{}, nil + logger.Debug("Creating new MatchHandler instance") + return &handlers.MatchHandler{}, nil }); err != nil { logger.Error("Unable to register match: %v", err) return err } - // Register matchmaker matched hook - when 4 players are matched, create an authoritative match + // 注册匹配器匹配钩子 if err := initializer.RegisterMatchmakerMatched(func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, entries []runtime.MatchmakerEntry) (string, error) { logger.Info("Matchmaker matched! Creating authoritative match for %d players", len(entries)) - // Create an authoritative match using our custom handler matchId, err := nk.MatchCreate(ctx, "animal_minesweeper", nil) if err != nil { logger.Error("Failed to create match: %v", err) @@ -1188,6 +54,23 @@ func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runti return err } - logger.Error("Match registration completed successfully") + logger.Info("Match registration completed successfully") + + // 注册 RPC + if err := initializer.RegisterRpc("list_matches", handlers.RpcListMatches); err != nil { + logger.Error("Unable to register rpc: %v", err) + return err + } + + if err := initializer.RegisterRpc("find_my_match", handlers.RpcFindMyMatch); err != nil { + logger.Error("Unable to register rpc: %v", err) + return err + } + + if err := initializer.RegisterRpc("get_online_count", handlers.RpcGetOnlineCount); err != nil { + logger.Error("Unable to register rpc: %v", err) + return err + } + return nil } diff --git a/server/wuziqi-server b/server/wuziqi-server new file mode 100755 index 0000000..be6b3c0 Binary files /dev/null and b/server/wuziqi-server differ diff --git a/test_matchmaking.js b/test_matchmaking.js new file mode 100644 index 0000000..9ca8e4f --- /dev/null +++ b/test_matchmaking.js @@ -0,0 +1,226 @@ +#!/usr/bin/env node +/** + * 扫雷游戏匹配测试脚本 + * 测试多个玩家同时匹配和加入游戏的流程 + */ + +const WebSocket = require('ws'); + +const NAKAMA_HOST = 'wss://nakama.bindbox.cn'; // 修改为你的服务器地址 +const NUM_PLAYERS = 4; + +// 模拟游戏token(实际应该从后端获取) +const TEST_TOKENS = [ + // 这些是mock token,需要替换为真实的game_token + 'test_token_player_1', + 'test_token_player_2', + 'test_token_player_3', + 'test_token_player_4', +]; + +class TestPlayer { + constructor(index, nakamaToken, gameToken) { + this.index = index; + this.nakamaToken = nakamaToken; + this.gameToken = gameToken; + this.ws = null; + this.matchId = null; + this.connected = false; + } + + async connect() { + return new Promise((resolve, reject) => { + const url = `${NAKAMA_HOST}/ws?token=${this.nakamaToken}&format=json`; + console.log(`[Player ${this.index}] Connecting to ${url.substring(0, 80)}...`); + + this.ws = new WebSocket(url); + + this.ws.on('open', () => { + console.log(`[Player ${this.index}] ✅ WebSocket connected`); + this.connected = true; + resolve(); + }); + + this.ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + this.handleMessage(msg); + } catch (e) { + console.error(`[Player ${this.index}] Failed to parse message:`, e); + } + }); + + this.ws.on('error', (err) => { + console.error(`[Player ${this.index}] ❌ WebSocket error:`, err.message); + reject(err); + }); + + this.ws.on('close', (code, reason) => { + console.log(`[Player ${this.index}] WebSocket closed: ${code} - ${reason}`); + this.connected = false; + }); + + setTimeout(() => { + if (!this.connected) { + reject(new Error('Connection timeout')); + } + }, 10000); + }); + } + + handleMessage(msg) { + console.log(`[Player ${this.index}] Received:`, JSON.stringify(msg).substring(0, 200)); + + // 匹配成功 + if (msg.matchmaker_matched) { + const matchId = msg.matchmaker_matched.match_id; + console.log(`[Player ${this.index}] 🎮 Matchmaker matched! Match ID: ${matchId}`); + this.matchId = matchId; + + // 自动加入比赛 + setTimeout(() => { + this.joinMatch(matchId); + }, 100); + } + + // 比赛加入结果 + if (msg.match) { + console.log(`[Player ${this.index}] ✅ Successfully joined match!`); + } + + // 错误信息 + if (msg.error) { + console.error(`[Player ${this.index}] ❌ Error:`, msg.error.message); + } + + // 游戏状态更新 + if (msg.match_data) { + const opCode = msg.match_data.op_code; + console.log(`[Player ${this.index}] 📦 Match data received, op_code: ${opCode}`); + } + } + + addToMatchmaker() { + if (!this.connected) { + console.error(`[Player ${this.index}] Not connected!`); + return; + } + + const request = { + cid: `${this.index}_matchmaker`, + matchmaker_add: { + min_count: NUM_PLAYERS, + max_count: NUM_PLAYERS, + query: '*', + string_properties: { + game_token: this.gameToken + } + } + }; + + console.log(`[Player ${this.index}] 🔍 Adding to matchmaker...`); + this.ws.send(JSON.stringify(request)); + } + + joinMatch(matchId) { + if (!this.connected) { + console.error(`[Player ${this.index}] Not connected!`); + return; + } + + const request = { + cid: `${this.index}_join`, + match_join: { + match_id: matchId, + metadata: { + game_token: this.gameToken // 关键:加入时必须带game_token + } + } + }; + + console.log(`[Player ${this.index}] 🚪 Joining match with game_token...`); + this.ws.send(JSON.stringify(request)); + } + + // 测试:不带game_token加入 + joinMatchWithoutToken(matchId) { + const request = { + cid: `${this.index}_join_no_token`, + match_join: { + match_id: matchId + // 没有 metadata.game_token + } + }; + + console.log(`[Player ${this.index}] 🚪 Joining match WITHOUT game_token (should fail)...`); + this.ws.send(JSON.stringify(request)); + } + + disconnect() { + if (this.ws) { + this.ws.close(); + } + } +} + +async function main() { + console.log('=== 扫雷游戏匹配测试 ===\n'); + console.log(`测试玩家数: ${NUM_PLAYERS}`); + console.log(`服务器地址: ${NAKAMA_HOST}\n`); + + // 步骤1: 获取Nakama认证token(需要替换为实际的认证逻辑) + console.log('⚠️ 注意: 此脚本需要有效的Nakama认证token和game_token'); + console.log('请确保已配置正确的token后运行\n'); + + // 创建测试玩家 + const players = []; + + // 这里需要替换为实际的Nakama token + // 可以通过调用后端API获取 + const nakamaTokens = [ + // 示例token格式,需要替换 + 'YOUR_NAKAMA_TOKEN_1', + 'YOUR_NAKAMA_TOKEN_2', + 'YOUR_NAKAMA_TOKEN_3', + 'YOUR_NAKAMA_TOKEN_4', + ]; + + for (let i = 0; i < NUM_PLAYERS; i++) { + players.push(new TestPlayer(i + 1, nakamaTokens[i], TEST_TOKENS[i])); + } + + try { + // 步骤2: 连接所有玩家 + console.log('--- 步骤1: 连接玩家 ---'); + await Promise.all(players.map(p => p.connect())); + console.log('✅ 所有玩家已连接\n'); + + // 步骤3: 添加到匹配队列 + console.log('--- 步骤2: 添加到匹配队列 ---'); + for (const player of players) { + player.addToMatchmaker(); + await sleep(200); // 稍微错开请求 + } + + // 等待匹配完成 + console.log('\n⏳ 等待匹配完成...'); + await sleep(15000); + + console.log('\n--- 测试完成 ---'); + + } catch (error) { + console.error('测试失败:', error); + } finally { + // 断开所有连接 + for (const player of players) { + player.disconnect(); + } + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// 运行测试 +main().catch(console.error); diff --git a/test_reconnect.js b/test_reconnect.js new file mode 100644 index 0000000..c4eda09 --- /dev/null +++ b/test_reconnect.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * Reconnect 测试脚本 + * 1. 连接玩家 + * 2. 加入匹配 + * 3. 等待匹配完成后,模拟断线并重新连接,使用相同的 game_token 重新加入同一场比赛 + */ + +const WebSocket = require('ws'); + +// 请根据实际情况修改以下配置 +const NAKAMA_HOST = 'ws://127.0.0.1:7350/ws'; // 本地 Nakama 地址 +const NUM_PLAYERS = 2; // 只测试 2 人即可 + +// 这里需要填入真实的 Nakama 认证 token 和 game_token +const NAKAMA_TOKENS = [ + 'YOUR_NAKAMA_TOKEN_1', + 'YOUR_NAKAMA_TOKEN_2', +]; +const GAME_TOKENS = [ + 'YOUR_GAME_TOKEN_1', + 'YOUR_GAME_TOKEN_2', +]; + +class TestPlayer { + constructor(id, nakamaToken, gameToken) { + this.id = id; + this.nakamaToken = nakamaToken; + this.gameToken = gameToken; + this.ws = null; + this.matchId = null; + } + + connect() { + return new Promise((resolve, reject) => { + const url = `${NAKAMA_HOST}?token=${this.nakamaToken}&format=json`; + this.ws = new WebSocket(url); + this.ws.on('open', () => { + console.log(`[${this.id}] ✅ WS connected`); + resolve(); + }); + this.ws.on('message', (data) => this.handleMessage(JSON.parse(data))); + this.ws.on('error', (e) => reject(e)); + }); + } + + handleMessage(msg) { + if (msg.matchmaker_matched) { + this.matchId = msg.matchmaker_matched.match_id; + console.log(`[${this.id}] 🎮 Matched, matchId=${this.matchId}`); + // 自动加入 + setTimeout(() => this.joinMatch(this.matchId), 100); + } + if (msg.match) { + console.log(`[${this.id}] ✅ Joined match ${msg.match.match_id}`); + } + if (msg.error) { + console.error(`[${this.id}] ❌ Error: ${msg.error.message}`); + } + } + + addToMatchmaker() { + const req = { + cid: `${this.id}_mm`, + matchmaker_add: { + min_count: NUM_PLAYERS, + max_count: NUM_PLAYERS, + query: '*', + string_properties: { game_token: this.gameToken }, + }, + }; + this.ws.send(JSON.stringify(req)); + console.log(`[${this.id}] 🔍 Added to matchmaker`); + } + + joinMatch(matchId) { + const req = { + cid: `${this.id}_join`, + match_join: { + match_id: matchId, + metadata: { game_token: this.gameToken }, + }, + }; + this.ws.send(JSON.stringify(req)); + console.log(`[${this.id}] 🚪 Joining match ${matchId}`); + } + + disconnect() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + console.log(`[${this.id}] 🔌 Disconnected`); + } +} + +async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +(async () => { + const players = []; + for (let i = 0; i < NUM_PLAYERS; i++) { + players.push(new TestPlayer(i + 1, NAKAMA_TOKENS[i], GAME_TOKENS[i])); + } + + // 1. 连接 + await Promise.all(players.map(p => p.connect())); + + // 2. 加入匹配队列 + players.forEach(p => p.addToMatchmaker()); + + // 等待匹配完成 + await sleep(15000); + + // 3. 模拟断线并重连(只对第一个玩家演示) + const p = players[0]; + console.log(`\n🔌 模拟玩家 ${p.id} 断线`); + const oldMatchId = p.matchId; + p.disconnect(); + await sleep(2000); + console.log(`🔁 重新连接玩家 ${p.id}`); + await p.connect(); + // 使用相同的 game_token 重新加入同一场比赛 + if (oldMatchId) { + p.joinMatch(oldMatchId); + } + + // 等待几秒观察结果 + await sleep(10000); + + // 清理 + players.forEach(p => p.disconnect()); + console.log('✅ 测试结束'); +})(); diff --git a/游戏逻辑文档.txt b/游戏逻辑文档.txt index 3384e3e..b1c3787 100644 --- a/游戏逻辑文档.txt +++ b/游戏逻辑文档.txt @@ -27,13 +27,12 @@ 10. 宝箱 游戏结束玩家获得1个宝箱,宝箱可以开出随机的道具卡碎片,4个碎片合成一个道具卡 11. 诅咒 下一次受伤的伤害值乘以2 # 角色逻辑 -1. 大象 血量初始化为5,无法使用医疗包、好人卡、复活甲这三种道具 -2. 猫咪 血量初始化为3,所有受到的伤害强制为1点(包括诅咒加成) +1. 大象 血量初始化为5,但不允许使用好人卡和医疗包,复活甲 +2. 猫咪 血量初始化为3,所有伤害强制为1(包括诅咒) 3. 汪汪 4(6)人赛中,每6(9)回合触发一次放大镜的道具效果,获得一个随机格子信息 -4. 猴子 每回合有15%概率获得一个香蕉(回复1点血量),每局最多生效2次 -5. 坤坤 每次被伤害,有概率获得一个道具,道具只能是好人卡、护盾、放大镜,概率为8%,每局最多生效2次 +4. 吉吉国王 每回合概率获得一个香蕉,香蕉恢复1点血量,一局游戏中最多生效2次,概率为15% +5. 坤坤 每次被伤害,有概率获得一个道具,道具只能是好人卡,护盾,放大镜,概率为8%,每局最多生效2次。 6. 懒懒 免疫毒药瓶,定时炸弹的伤害降低为1 7. 河马 无法拾取道具,但有概率免疫死亡,每局最多生效1次,并且概率为55% 8. 老虎 当老虎在场上时,使用飞刀的伤害变为全体伤害,并且伤害为2 -