feat: 重构游戏服务器架构,新增游戏逻辑、物品、角色系统及配置管理,并更新匹配与RPC处理器
This commit is contained in:
parent
775fc7d64e
commit
7e5f77ffd4
BIN
app/dist.zip
BIN
app/dist.zip
Binary file not shown.
169
app/src/App.tsx
169
app/src/App.tsx
@ -17,6 +17,8 @@ interface Player {
|
||||
revive: boolean;
|
||||
timeBombTurns: number;
|
||||
skipTurn: boolean;
|
||||
// 放大镜透视的格子 {cellIndex: contentType}
|
||||
revealedCells?: { [cellIndex: number]: string };
|
||||
}
|
||||
|
||||
interface GridCell {
|
||||
@ -177,73 +179,38 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 再来一局: 获取新token并重新匹配
|
||||
// 再来一局: 通知小程序返回游戏入口页面
|
||||
const refreshAndPlayAgain = async () => {
|
||||
console.log('🔄 [再来一局] 通知小程序返回游戏入口...');
|
||||
addLog('system', '🔄 正在返回游戏入口...');
|
||||
setIsRefreshing(true);
|
||||
addLog('system', '🔄 正在获取新的游戏凭证...');
|
||||
|
||||
try {
|
||||
const backendUrl = 'https://game.1024tool.vip';
|
||||
// 从小程序环境获取用户token
|
||||
let userToken = '';
|
||||
if ((window as any).uni && (window as any).uni.getStorageSync) {
|
||||
userToken = (window as any).uni.getStorageSync('token') || '';
|
||||
}
|
||||
|
||||
if (!userToken) {
|
||||
addLog('system', '❌ 无法获取用户凭证,请返回小程序重新进入');
|
||||
setIsRefreshing(false);
|
||||
// 回退到小程序
|
||||
if ((window as any).uni) {
|
||||
(window as any).uni.navigateBack();
|
||||
} else if ((window as any).wx?.miniProgram) {
|
||||
(window as any).wx.miniProgram.navigateBack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`${backendUrl}/api/app/games/enter`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${userToken}`
|
||||
},
|
||||
body: JSON.stringify({ game_code: 'minesweeper' })
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
addLog('system', `❌ 获取游戏凭证失败: ${errData.message || '游戏次数不足'}`);
|
||||
setIsRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
if (data.game_token) {
|
||||
addLog('system', '✅ 获取新凭证成功!');
|
||||
|
||||
// 重置游戏状态
|
||||
setGameState(null);
|
||||
setMatchId(null);
|
||||
setIsMatching(false);
|
||||
|
||||
// 重新初始化 Nakama 并开始匹配
|
||||
nakamaManager.initClient(data.nakama_server || 'wss://nakama.1024tool.vip', data.nakama_key || 'defaultkey');
|
||||
await nakamaManager.authenticateWithGameToken(data.game_token);
|
||||
setIsConnected(true);
|
||||
addLog('system', '✅ 重新连接成功');
|
||||
|
||||
// 自动开始匹配
|
||||
setTimeout(() => startMatchmaking(), 500);
|
||||
} else {
|
||||
addLog('system', '❌ 获取游戏凭证失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Refresh token error:', err);
|
||||
addLog('system', '❌ 网络错误,请稍后重试');
|
||||
// 方案1: 使用 postMessage 通知小程序
|
||||
if ((window as any).wx?.miniProgram) {
|
||||
console.log(' -> 使用 wx.miniProgram.postMessage + navigateBack');
|
||||
(window as any).wx.miniProgram.postMessage({ data: { action: 'playAgain' } });
|
||||
setTimeout(() => {
|
||||
(window as any).wx.miniProgram.navigateBack();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案2: 使用 uni 的 redirectTo 直接跳转到游戏入口页面
|
||||
if ((window as any).uni) {
|
||||
console.log(' -> 使用 uni.navigateBack');
|
||||
// 先发送消息再返回
|
||||
(window as any).uni.postMessage({ data: { action: 'playAgain' } });
|
||||
setTimeout(() => {
|
||||
(window as any).uni.navigateBack();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案3: 浏览器环境 - 刷新页面(需要新token)
|
||||
console.log(' -> 浏览器环境, 刷新页面');
|
||||
addLog('system', '❌ 浏览器环境无法自动获取新token,请手动刷新页面');
|
||||
setIsRefreshing(false);
|
||||
alert('请手动刷新页面或从小程序重新进入游戏');
|
||||
};
|
||||
|
||||
const GuideModal = () => (
|
||||
@ -449,8 +416,26 @@ const App: React.FC = () => {
|
||||
// Special event from server (character abilities, item effects)
|
||||
const event = data as unknown as { type: string; playerId: string; playerName: string; targetId?: string; targetName?: string; itemId?: string; value?: number; message: string };
|
||||
const isMe = event.playerId === myUserIdRef.current;
|
||||
const prefix = isMe ? '你' : (event.playerName?.substring(0, 8) || '对手');
|
||||
addLog('effect', `${prefix}: ${event.message}`);
|
||||
const iAmTarget = event.targetId === myUserIdRef.current;
|
||||
|
||||
// 翻译道具名称
|
||||
const itemNames: { [key: string]: string } = {
|
||||
knife: '🔪飞刀', lightning: '⚡闪电', poison: '☠️毒药',
|
||||
curse: '👻诅咒', bomb_timer: '💣定时炸弹'
|
||||
};
|
||||
const itemDisplayName = event.itemId ? (itemNames[event.itemId] || event.itemId) : '';
|
||||
|
||||
// 显示日志
|
||||
if (isMe) {
|
||||
addLog('effect', `你: ${event.message}`);
|
||||
} else if (iAmTarget && event.type === 'damage') {
|
||||
// 我被别人攻击了 - 特别提示
|
||||
const attackerName = event.playerName?.substring(0, 8) || '对手';
|
||||
addLog('effect', `⚠️ 你被 ${attackerName} 用 ${itemDisplayName} 攻击了!`);
|
||||
} else {
|
||||
const prefix = event.playerName?.substring(0, 8) || '对手';
|
||||
addLog('effect', `${prefix}: ${event.message}`);
|
||||
}
|
||||
|
||||
// Show floating damage/heal for affected players
|
||||
if (event.type === 'damage' || event.type === 'item') {
|
||||
@ -459,7 +444,7 @@ const App: React.FC = () => {
|
||||
if (isGlobalDamage && event.value) {
|
||||
// Global damage - show on my screen
|
||||
spawnLabel(50, 50, `-${event.value}`, 'damage', undefined, myUserIdRef.current || undefined);
|
||||
} else if (event.targetId === myUserIdRef.current && event.value) {
|
||||
} else if (iAmTarget && event.value) {
|
||||
// I'm the target of damage
|
||||
spawnLabel(50, 50, `-${event.value}`, 'damage', undefined, myUserIdRef.current || undefined);
|
||||
}
|
||||
@ -473,7 +458,7 @@ const App: React.FC = () => {
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
addLog('system', '连接服务器失败');
|
||||
}
|
||||
};
|
||||
@ -536,16 +521,35 @@ const App: React.FC = () => {
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -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:
|
||||
# 直接使用服务名访问后端
|
||||
|
||||
@ -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.
129
server/characters/characters_test.go
Normal file
129
server/characters/characters_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
111
server/characters/manager.go
Normal file
111
server/characters/manager.go
Normal 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
171
server/config/config.go
Normal 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
254
server/core/types.go
Normal 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
460
server/game_test_runner.go
Normal 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
570
server/handlers/match.go
Normal 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
146
server/handlers/rpc.go
Normal 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
226
server/items/effects.go
Normal 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
30
server/items/interface.go
Normal 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
338
server/items/items_test.go
Normal 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
45
server/items/manager.go
Normal 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
241
server/logic/combat.go
Normal 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)
|
||||
}
|
||||
749
server/logic/comprehensive_test.go
Normal file
749
server/logic/comprehensive_test.go
Normal 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应该是2,got %d", state.Players["p1"].HP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItem_BombTimer_SlothReduction(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "sloth", Character: "sloth", HP: 4, TimeBomb: 1},
|
||||
{ID: "p2", Character: "cat", HP: 3},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
state.CurrentTurnIndex = 1
|
||||
engine.AdvanceTurn(state)
|
||||
|
||||
if state.Players["sloth"].HP != 3 {
|
||||
t.Errorf("树懒定时炸弹伤害应该减为1,HP应该是3,got %d", state.Players["sloth"].HP)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 毒药瓶:随机让一个存活玩家中毒,每2回合扣1血
|
||||
func TestItem_Poison_BasicEffect(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "monkey", HP: 4}, // 非树懒
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "poison"},
|
||||
},
|
||||
})
|
||||
|
||||
engine.HandleMove(state, "p1", 0)
|
||||
|
||||
// p2 应该中毒(只有一个非使用者目标)
|
||||
if !state.Players["p2"].Poisoned {
|
||||
t.Error("毒药应该使目标中毒")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItem_Poison_DamagePerTwoTurns(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4, Poisoned: true},
|
||||
{ID: "p2", Character: "cat", HP: 3},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
// 第1回合:PoisonSteps=1,不扣血
|
||||
state.CurrentTurnIndex = 1
|
||||
engine.AdvanceTurn(state)
|
||||
if state.Players["p1"].HP != 4 {
|
||||
t.Errorf("第1回合不应该扣血,got HP=%d", state.Players["p1"].HP)
|
||||
}
|
||||
|
||||
// 第2回合:PoisonSteps=2,扣1血
|
||||
state.CurrentTurnIndex = 1
|
||||
engine.AdvanceTurn(state)
|
||||
if state.Players["p1"].HP != 3 {
|
||||
t.Errorf("第2回合应该扣1血,got HP=%d", state.Players["p1"].HP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItem_Poison_SlothImmune(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "毒药-树懒免疫",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "sloth", Character: "sloth", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "poison"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "sloth", Field: "poisoned", Expected: false, Message: "树懒应该免疫毒药"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 护盾:免疫1次伤害
|
||||
func TestItem_Shield_BlockDamage(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "护盾-抵挡伤害",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4, Shield: true},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "bomb"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "p1", Field: "hp", Expected: 4, Message: "护盾应该抵挡炸弹伤害"},
|
||||
{PlayerID: "p1", Field: "shield", Expected: false, Message: "护盾应该被消耗"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 好人卡:跳过回合+护盾
|
||||
func TestItem_Skip_Effect(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "好人卡-跳过回合并获得护盾",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "cat", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "skip"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "p1", Field: "skip_turn", Expected: true, Message: "应该设置跳过回合"},
|
||||
{PlayerID: "p1", Field: "shield", Expected: true, Message: "应该获得护盾"},
|
||||
{PlayerID: "p2", Field: "current_turn", Expected: true, Message: "回合应该切换到p2"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestItem_Skip_ElephantCannotUse(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "好人卡-大象无法使用",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "elephant", Character: "elephant", HP: 5},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "skip"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "elephant", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "elephant", Field: "skip_turn", Expected: false, Message: "大象无法使用好人卡"},
|
||||
{PlayerID: "elephant", Field: "shield", Expected: false, Message: "大象不应该获得护盾"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 6. 放大镜:透视一个随机格子
|
||||
func TestItem_Magnifier_RevealCell(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "magnifier"},
|
||||
{Index: 5, Type: "bomb"}, // 未揭示的炸弹
|
||||
},
|
||||
})
|
||||
|
||||
engine.HandleMove(state, "p1", 0)
|
||||
|
||||
if len(state.Players["p1"].RevealedCells) == 0 {
|
||||
t.Error("放大镜应该揭示一个格子")
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 飞刀:对随机敌人造成1点伤害
|
||||
func TestItem_Knife_BasicDamage(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "飞刀-基础伤害",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "monkey", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "knife"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "p2", Field: "hp", Expected: 3, Message: "飞刀应该造成1点伤害"},
|
||||
{PlayerID: "p1", Field: "hp", Expected: 4, Message: "使用者不受伤害"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestItem_Knife_TigerAOE(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "飞刀-老虎在场变全体2点伤害",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "monkey", HP: 4},
|
||||
{ID: "tiger", Character: "tiger", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "knife"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "p2", Field: "hp", Expected: 2, Message: "老虎在场,飞刀应该造成2点伤害"},
|
||||
{PlayerID: "tiger", Field: "hp", Expected: 2, Message: "老虎自己也受2点伤害"},
|
||||
{PlayerID: "p1", Field: "hp", Expected: 4, Message: "使用者不受伤害"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 8. 复活甲:免疫一次死亡,保留1点血量
|
||||
func TestItem_Revive_SaveFromDeath(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "复活甲-免疫死亡",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 1, Revive: true},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "bomb"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "p1", Field: "hp", Expected: 1, Message: "复活甲应该保留1点血量"},
|
||||
{PlayerID: "p1", Field: "alive", Expected: true, Message: "玩家应该存活"},
|
||||
{PlayerID: "p1", Field: "revive", Expected: false, Message: "复活甲应该被消耗"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestItem_Revive_ElephantCannotUse(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "复活甲-大象无法使用",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "elephant", Character: "elephant", HP: 5},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "revive"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "elephant", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "elephant", Field: "revive", Expected: false, Message: "大象无法使用复活甲"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 闪电:对所有玩家造成1点伤害
|
||||
func TestItem_Lightning_AllDamage(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "闪电-全体伤害",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "monkey", HP: 4},
|
||||
{ID: "p3", Character: "tiger", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "lightning"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "p1", Field: "hp", Expected: 3, Message: "闪电对使用者也造成伤害"},
|
||||
{PlayerID: "p2", Field: "hp", Expected: 3, Message: "闪电对p2造成伤害"},
|
||||
{PlayerID: "p3", Field: "hp", Expected: 3, Message: "闪电对p3造成伤害"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 10. 宝箱:游戏结束后获得奖励
|
||||
func TestItem_Chest_Counter(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "chest"},
|
||||
},
|
||||
})
|
||||
|
||||
engine.HandleMove(state, "p1", 0)
|
||||
|
||||
if state.Players["p1"].ChestCount != 1 {
|
||||
t.Errorf("宝箱计数应该是1,got %d", state.Players["p1"].ChestCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 诅咒:下次受伤翻倍
|
||||
func TestItem_Curse_DoubleDamage(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "诅咒-伤害翻倍",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4, Curse: true},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "bomb"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "p1", Value: 0}, // 炸弹2伤害 * 2 = 4
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "p1", Field: "hp", Expected: 0, Message: "诅咒应该让伤害翻倍"},
|
||||
{PlayerID: "p1", Field: "curse", Expected: false, Message: "诅咒应该被消耗"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestItem_Curse_CatIgnores(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "诅咒-猫咪无视翻倍",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "cat", Character: "cat", HP: 3, Curse: true},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "bomb"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "cat", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "cat", Field: "hp", Expected: 2, Message: "猫咪诅咒也只受1点伤害"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 角色逻辑测试 (8个角色)
|
||||
// ============================================================
|
||||
|
||||
// 1. 大象:5点血,无法使用好人卡/医疗包/复活甲
|
||||
func TestCharacter_Elephant_HP(t *testing.T) {
|
||||
charMgr := characters.NewCharacterManager(nil)
|
||||
hp := charMgr.GetInitialHP("elephant", 4)
|
||||
if hp != 5 {
|
||||
t.Errorf("大象初始血量应该是5,got %d", hp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacter_Elephant_ItemRestrictions(t *testing.T) {
|
||||
// 已在上面的道具测试中覆盖
|
||||
t.Log("大象道具限制已在道具测试中覆盖")
|
||||
}
|
||||
|
||||
// 2. 猫咪:3点血,所有伤害强制为1(包括诅咒)
|
||||
func TestCharacter_Cat_HP(t *testing.T) {
|
||||
charMgr := characters.NewCharacterManager(nil)
|
||||
hp := charMgr.GetInitialHP("cat", 4)
|
||||
if hp != 3 {
|
||||
t.Errorf("猫咪初始血量应该是3,got %d", hp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharacter_Cat_DamageCap(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "猫咪-伤害限制为1",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "cat", Character: "cat", HP: 3},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "bomb"}, // 2点伤害
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "cat", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "cat", Field: "hp", Expected: 2, Message: "猫咪受到任何伤害都只扣1点"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 汪汪:4(6)人赛每6(9)回合触发放大镜
|
||||
func TestCharacter_Dog_MagnifierAbility(t *testing.T) {
|
||||
// 由于狗狗技能依赖于 GlobalTurnCount % 6 == 0,
|
||||
// 且只有狗狗自己行动时才会触发,我们直接测试逻辑
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "dog", Character: "dog", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
// 创建足够的格子
|
||||
state.Grid = make([]*core.GridCell, 100)
|
||||
for i := range state.Grid {
|
||||
state.Grid[i] = &core.GridCell{Type: "empty", Revealed: false}
|
||||
}
|
||||
state.Grid[99].Type = "bomb" // 放一个未揭示的炸弹
|
||||
|
||||
// 直接设置 GlobalTurnCount 为 5,下一次操作将使其变为 6
|
||||
state.GlobalTurnCount = 5
|
||||
|
||||
// 狗狗操作,GlobalTurnCount 变为 6,6 % 6 == 0,应该触发
|
||||
engine.HandleMove(state, "dog", 0)
|
||||
|
||||
t.Logf("操作后 GlobalTurnCount=%d, RevealedCells=%d",
|
||||
state.GlobalTurnCount, len(state.Players["dog"].RevealedCells))
|
||||
|
||||
// 检查是否触发了放大镜
|
||||
if len(state.Players["dog"].RevealedCells) == 0 {
|
||||
t.Errorf("狗狗应该在 GlobalTurnCount=6 时触发放大镜能力")
|
||||
} else {
|
||||
t.Logf("狗狗放大镜触发成功,揭示了 %d 个格子", len(state.Players["dog"].RevealedCells))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 吉吉国王(猴子):每回合15%概率获得香蕉恢复1血,最多2次
|
||||
func TestCharacter_Monkey_BananaAbility(t *testing.T) {
|
||||
// 由于是概率性的,我们测试计数器限制
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "monkey", Character: "monkey", HP: 2},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
// 手动设置已触发2次
|
||||
state.Players["monkey"].MonkeyBananaCount = 2
|
||||
|
||||
// 模拟移动,不应该再触发
|
||||
for i := 0; i < 10; i++ {
|
||||
state.Grid = append(state.Grid, &core.GridCell{Type: "empty", Revealed: false})
|
||||
}
|
||||
|
||||
initialHP := state.Players["monkey"].HP
|
||||
engine.HandleMove(state, "monkey", 0)
|
||||
|
||||
// 由于已经触发2次,不应该再恢复
|
||||
if state.Players["monkey"].MonkeyBananaCount > 2 {
|
||||
t.Error("猴子香蕉能力最多触发2次")
|
||||
}
|
||||
|
||||
t.Logf("猴子HP变化: %d -> %d (计数: %d)", initialHP, state.Players["monkey"].HP, state.Players["monkey"].MonkeyBananaCount)
|
||||
}
|
||||
|
||||
// 5. 坤坤(小鸡):受伤8%概率获得道具,最多2次
|
||||
func TestCharacter_Chicken_ItemOnDamage(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "chicken", Character: "chicken", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
// 手动设置已触发2次
|
||||
state.Players["chicken"].ChickenItemCount = 2
|
||||
|
||||
// 造成伤害,不应该再触发
|
||||
engine.ApplyDamage(state, state.Players["chicken"], 1, false)
|
||||
|
||||
if state.Players["chicken"].ChickenItemCount > 2 {
|
||||
t.Error("小鸡道具能力最多触发2次")
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 懒懒(树懒):免疫毒药,炸弹伤害减半
|
||||
func TestCharacter_Sloth_PoisonImmune(t *testing.T) {
|
||||
// 已在毒药测试中覆盖
|
||||
t.Log("树懒毒药免疫已在道具测试中覆盖")
|
||||
}
|
||||
|
||||
func TestCharacter_Sloth_BombDamageReduction(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "树懒-炸弹伤害减半",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "sloth", Character: "sloth", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "bomb"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "sloth", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "sloth", Field: "hp", Expected: 3, Message: "树懒踩炸弹只受1点伤害"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 河马:无法拾取道具,55%概率免疫死亡(最多1次)
|
||||
func TestCharacter_Hippo_CannotPickItems(t *testing.T) {
|
||||
RunScenario(t, GameScenario{
|
||||
Name: "河马-无法拾取道具",
|
||||
Players: []PlayerSetup{
|
||||
{ID: "hippo", Character: "hippo", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "item", ItemID: "shield"},
|
||||
},
|
||||
Actions: []GameAction{
|
||||
{Type: "move", PlayerID: "hippo", Value: 0},
|
||||
},
|
||||
Checks: []ScenarioCheck{
|
||||
{PlayerID: "hippo", Field: "shield", Expected: false, Message: "河马无法拾取道具"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCharacter_Hippo_DeathResistOnce(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "hippo", Character: "hippo", HP: 1},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
// 模拟多次死亡尝试
|
||||
deathResistTriggered := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
// 重置状态
|
||||
state.Players["hippo"].HP = 1
|
||||
state.Players["hippo"].HippoDeathImmune = false
|
||||
|
||||
engine.ApplyDamage(state, state.Players["hippo"], 10, false)
|
||||
|
||||
if state.Players["hippo"].HP > 0 {
|
||||
deathResistTriggered++
|
||||
if state.Players["hippo"].HippoDeathImmune != true {
|
||||
t.Error("河马死亡抵抗标志应该被设置")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("河马死亡抵抗触发次数: %d/100 (预期约55%%)", deathResistTriggered)
|
||||
|
||||
// 测试已触发后不再生效
|
||||
state.Players["hippo"].HP = 1
|
||||
state.Players["hippo"].HippoDeathImmune = true
|
||||
engine.ApplyDamage(state, state.Players["hippo"], 10, false)
|
||||
|
||||
if state.Players["hippo"].HP > 0 {
|
||||
t.Error("河马死亡抵抗应该只能触发一次")
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 老虎:飞刀变全体2点伤害
|
||||
func TestCharacter_Tiger_KnifeEnhance(t *testing.T) {
|
||||
// 已在飞刀测试中覆盖
|
||||
t.Log("老虎飞刀增强已在道具测试中覆盖")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 游戏流程测试
|
||||
// ============================================================
|
||||
|
||||
func TestGameFlow_GameOver(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "cat", HP: 1},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "bomb"},
|
||||
},
|
||||
})
|
||||
|
||||
// p2 踩炸弹死亡
|
||||
state.CurrentTurnIndex = 1
|
||||
engine.HandleMove(state, "p2", 0)
|
||||
|
||||
if state.WinnerID != "p1" {
|
||||
t.Errorf("p1应该获胜,got winner=%s", state.WinnerID)
|
||||
}
|
||||
if state.GameStarted {
|
||||
t.Error("游戏应该结束")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameFlow_SafeAreaExpansion(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
// 创建3x3网格,右下角有炸弹
|
||||
state.GridSize = 3
|
||||
state.Grid = make([]*core.GridCell, 9)
|
||||
for i := range state.Grid {
|
||||
state.Grid[i] = &core.GridCell{Type: "empty", Revealed: false, NeighborBombs: 0}
|
||||
}
|
||||
state.Grid[8].Type = "bomb"
|
||||
state.Grid[4].NeighborBombs = 1 // 中间格子有1个邻居炸弹
|
||||
state.Grid[5].NeighborBombs = 1
|
||||
state.Grid[7].NeighborBombs = 1
|
||||
|
||||
// 点击左上角
|
||||
engine.HandleMove(state, "p1", 0)
|
||||
|
||||
// 统计揭示的格子
|
||||
revealedCount := 0
|
||||
for _, cell := range state.Grid {
|
||||
if cell.Revealed {
|
||||
revealedCount++
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("揭示的格子数: %d", revealedCount)
|
||||
|
||||
if revealedCount < 5 {
|
||||
t.Errorf("安全区扩散应该揭示更多格子,只揭示了 %d 个", revealedCount)
|
||||
}
|
||||
|
||||
if state.Grid[8].Revealed {
|
||||
t.Error("炸弹不应该被揭示")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameFlow_TurnAdvance(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "cat", HP: 3},
|
||||
{ID: "p3", Character: "tiger", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{
|
||||
{Index: 0, Type: "empty"},
|
||||
},
|
||||
})
|
||||
|
||||
// p1 移动
|
||||
engine.HandleMove(state, "p1", 0)
|
||||
|
||||
// 回合应该推进到 p2
|
||||
if state.TurnOrder[state.CurrentTurnIndex] != "p2" {
|
||||
t.Errorf("回合应该推进到p2,当前是 %s", state.TurnOrder[state.CurrentTurnIndex])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameFlow_SkipTurn(t *testing.T) {
|
||||
engine, state := createScenarioState(GameScenario{
|
||||
Players: []PlayerSetup{
|
||||
{ID: "p1", Character: "dog", HP: 4},
|
||||
{ID: "p2", Character: "cat", HP: 3, SkipTurn: true},
|
||||
{ID: "p3", Character: "tiger", HP: 4},
|
||||
},
|
||||
Grid: []CellSetup{},
|
||||
})
|
||||
|
||||
// 从p1推进,应该跳过p2到p3
|
||||
state.CurrentTurnIndex = 0
|
||||
engine.AdvanceTurn(state)
|
||||
|
||||
if state.TurnOrder[state.CurrentTurnIndex] != "p3" {
|
||||
t.Errorf("应该跳过p2到p3,当前是 %s", state.TurnOrder[state.CurrentTurnIndex])
|
||||
}
|
||||
}
|
||||
199
server/logic/engine.go
Normal file
199
server/logic/engine.go
Normal 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)
|
||||
// ApplyDamage(isItemEffect=true 因为它来自道具?)
|
||||
// 是的,BombTimer 是一个道具。
|
||||
e.ApplyDamage(state, nextPlayer, dmg, true)
|
||||
|
||||
if nextPlayer.HP <= 0 {
|
||||
continue // 死于炸弹,跳过回合
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理毒药
|
||||
if nextPlayer.Poisoned {
|
||||
nextPlayer.PoisonSteps++
|
||||
if nextPlayer.PoisonSteps%2 == 0 {
|
||||
// 毒药伤害
|
||||
e.ApplyDamage(state, nextPlayer, 1, true)
|
||||
if nextPlayer.HP <= 0 {
|
||||
continue // 死于毒药,跳过回合
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理跳过
|
||||
if nextPlayer.SkipTurn {
|
||||
nextPlayer.SkipTurn = false
|
||||
e.Logger.Info("Player %s skipped turn", nextPlayer.UserID)
|
||||
continue
|
||||
}
|
||||
|
||||
// 找到有效玩家
|
||||
break
|
||||
}
|
||||
}
|
||||
137
server/logic/grid.go
Normal file
137
server/logic/grid.go
Normal 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
223
server/logic/logic_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
468
server/logic/scenario_test.go
Normal file
468
server/logic/scenario_test.go
Normal 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 自动发现和运行
|
||||
}
|
||||
1179
server/main.go
1179
server/main.go
File diff suppressed because it is too large
Load Diff
BIN
server/wuziqi-server
Executable file
BIN
server/wuziqi-server
Executable file
Binary file not shown.
226
test_matchmaking.js
Normal file
226
test_matchmaking.js
Normal 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
133
test_reconnect.js
Normal 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('✅ 测试结束');
|
||||
})();
|
||||
@ -27,13 +27,12 @@
|
||||
10. 宝箱 游戏结束玩家获得1个宝箱,宝箱可以开出随机的道具卡碎片,4个碎片合成一个道具卡
|
||||
11. 诅咒 下一次受伤的伤害值乘以2
|
||||
# 角色逻辑
|
||||
1. 大象 血量初始化为5,无法使用医疗包、好人卡、复活甲这三种道具
|
||||
2. 猫咪 血量初始化为3,所有受到的伤害强制为1点(包括诅咒加成)
|
||||
1. 大象 血量初始化为5,但不允许使用好人卡和医疗包,复活甲
|
||||
2. 猫咪 血量初始化为3,所有伤害强制为1(包括诅咒)
|
||||
3. 汪汪 4(6)人赛中,每6(9)回合触发一次放大镜的道具效果,获得一个随机格子信息
|
||||
4. 猴子 每回合有15%概率获得一个香蕉(回复1点血量),每局最多生效2次
|
||||
5. 坤坤 每次被伤害,有概率获得一个道具,道具只能是好人卡、护盾、放大镜,概率为8%,每局最多生效2次
|
||||
4. 吉吉国王 每回合概率获得一个香蕉,香蕉恢复1点血量,一局游戏中最多生效2次,概率为15%
|
||||
5. 坤坤 每次被伤害,有概率获得一个道具,道具只能是好人卡,护盾,放大镜,概率为8%,每局最多生效2次。
|
||||
6. 懒懒 免疫毒药瓶,定时炸弹的伤害降低为1
|
||||
7. 河马 无法拾取道具,但有概率免疫死亡,每局最多生效1次,并且概率为55%
|
||||
8. 老虎 当老虎在场上时,使用飞刀的伤害变为全体伤害,并且伤害为2
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user