feat: 重构游戏服务器架构,新增游戏逻辑、物品、角色系统及配置管理,并更新匹配与RPC处理器

This commit is contained in:
邹方成 2026-01-04 22:57:44 +08:00
parent 775fc7d64e
commit 7e5f77ffd4
28 changed files with 4980 additions and 1239 deletions

BIN
.DS_Store vendored

Binary file not shown.

Binary file not shown.

View File

@ -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 = () => {
<div className="text-emerald-400 font-bold">
🎮 {matchPlayerCount}
</div>
{matchingTimer > 3 && (
{matchingTimer > 3 && matchingTimer <= 30 && (
<div className="text-yellow-400 text-xs animate-pulse mt-3">
...1-5
</div>
)}
{matchingTimer > 8 && (
{matchingTimer > 30 && matchingTimer <= 60 && (
<div className="text-orange-400 text-xs mt-2">
...
</div>
)}
{matchingTimer > 60 && (
<div className="text-red-400 text-xs mt-2">
</div>
)}
{/* 取消匹配按钮 */}
<button
onClick={() => {
setIsMatching(false);
setMatchingTimer(0);
addLog('system', '已取消匹配');
// 刷新页面以完全重置状态
window.location.reload();
}}
className="mt-4 px-6 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-sm text-slate-300 transition-all"
>
</button>
</div>
</div>
) : (
@ -614,10 +618,23 @@ const App: React.FC = () => {
<div className="grid gap-0.5 lg:gap-1 w-full max-w-[500px] aspect-square" style={{ gridTemplateColumns: `repeat(${gameState.gridSize || 10}, 1fr)` }}>
{gameState.grid.map((cell, idx) => {
const showContent = cell.revealed || debugMode;
// 检查当前玩家是否放大镜透视了这个格子
const myRevealedContent = myPlayer?.revealedCells?.[idx];
const hasMagnifierMark = !!myRevealedContent && !cell.revealed;
return (
<div key={idx} onClick={() => 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'}
<div key={idx} onClick={() => 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 && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg lg:text-2xl opacity-80" title={`透视: ${myRevealedContent === 'bomb' ? '💣炸弹' : myRevealedContent === 'empty' ? '✅安全' : `🎁${myRevealedContent}`}`}>
{myRevealedContent === 'bomb' ? '💣' : myRevealedContent === 'empty' ? '✅' : '🎁'}
</span>
<span className="absolute top-0 right-0 text-xs">🔍</span>
</div>
)}
{showContent && (
cell.type === 'bomb' ? '💣' :
cell.type === 'item' ? getItemIcon(cell.itemId) :
@ -677,20 +694,6 @@ const App: React.FC = () => {
>
{isRefreshing ? '🔄 正在准备...' : '🚀 再来一局'}
</button>
<button
onClick={() => {
if ((window as any).uni) {
(window as any).uni.navigateBack();
} else if ((window as any).wx?.miniProgram) {
(window as any).wx.miniProgram.navigateBack();
} else {
window.location.href = 'about:blank';
}
}}
className="w-full mt-2 bg-slate-700 hover:bg-slate-600 text-white font-bold py-2 rounded-xl text-sm"
>
</button>
</div>
</div>
)}

View File

@ -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:
# 直接使用服务名访问后端

View File

@ -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

Binary file not shown.

View File

@ -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")
}
}

View File

@ -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
}

171
server/config/config.go Normal file
View File

@ -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")
}
}()
}

254
server/core/types.go Normal file
View File

@ -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 "未知道具"
}

460
server/game_test_runner.go Normal file
View File

@ -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("╚═══════════════════════════════════════════╝")
}

570
server/handlers/match.go Normal file
View File

@ -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)
}
}

146
server/handlers/rpc.go Normal file
View File

@ -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
}

226
server/items/effects.go Normal file
View File

@ -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
}

30
server/items/interface.go Normal file
View File

@ -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
}

338
server/items/items_test.go Normal file
View File

@ -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")
}
}

45
server/items/manager.go Normal file
View File

@ -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)
}

241
server/logic/combat.go Normal file
View File

@ -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)
}

View File

@ -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应该是2got %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("树懒定时炸弹伤害应该减为1HP应该是3got %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("宝箱计数应该是1got %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("大象初始血量应该是5got %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("猫咪初始血量应该是3got %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 变为 66 % 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])
}
}

199
server/logic/engine.go Normal file
View File

@ -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)
// ApplyDamageisItemEffect=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
}
}

137
server/logic/grid.go Normal file
View File

@ -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
}

223
server/logic/logic_test.go Normal file
View File

@ -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)
}
}

View File

@ -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 自动发现和运行
}

File diff suppressed because it is too large Load Diff

BIN
server/wuziqi-server Executable file

Binary file not shown.

226
test_matchmaking.js Normal file
View File

@ -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);

133
test_reconnect.js Normal file
View File

@ -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('✅ 测试结束');
})();

View File

@ -27,13 +27,12 @@
10. 宝箱 游戏结束玩家获得1个宝箱宝箱可以开出随机的道具卡碎片4个碎片合成一个道具卡
11. 诅咒 下一次受伤的伤害值乘以2
# 角色逻辑
1. 大象 血量初始化为5无法使用医疗包、好人卡、复活甲这三种道具
2. 猫咪 血量初始化为3所有受到的伤害强制为1(包括诅咒加成
1. 大象 血量初始化为5但不允许使用好人卡和医疗包,复活甲
2. 猫咪 血量初始化为3所有伤害强制为1包括诅咒
3. 汪汪 46人赛中每69回合触发一次放大镜的道具效果获得一个随机格子信息
4. 猴子 每回合有15%概率获得一个香蕉回复1点血量每局最多生效2次
5. 坤坤 每次被伤害,有概率获得一个道具,道具只能是好人卡、护盾、放大镜概率为8%每局最多生效2次
4. 吉吉国王 每回合概率获得一个香蕉香蕉恢复1点血量一局游戏中最多生效2次概率为15%
5. 坤坤 每次被伤害,有概率获得一个道具,道具只能是好人卡护盾放大镜概率为8%每局最多生效2次。
6. 懒懒 免疫毒药瓶定时炸弹的伤害降低为1
7. 河马 无法拾取道具但有概率免疫死亡每局最多生效1次并且概率为55%
8. 老虎 当老虎在场上时使用飞刀的伤害变为全体伤害并且伤害为2