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 = () => {
{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
-