commit 6dd576158c9daa97c779a9d735221d6c15784e87 Author: Misakiotoha <1841738040@qq.com> Date: Thu Jun 18 17:31:10 2026 +0800 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28f9fce --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# from ide +.vscode +.idea + +# Virtual environments +.venv +venv + +# config +settings.json + +# logs +log/* + +cmake-build-debug + + diff --git a/game/card.go b/game/card.go new file mode 100644 index 0000000..a47f581 --- /dev/null +++ b/game/card.go @@ -0,0 +1,104 @@ +package game + +import ( + "fmt" + "math/rand" + "time" +) + +type CardDef struct { + TypeID string + Name string + MP int + Priority int + Count int + Unusable bool +} + +type Card struct { + UID string + TypeID string + Name string + MP int +} + +var AllCardDefs = []CardDef{ + {"student_council_president", "学生会长", 3, 4, 1, false}, + {"class_monitor", "班长", 2, 4, 2, false}, + {"honor_student", "优等生", 2, 4, 3, false}, + {"discipline_committee_member", "风纪委员", 1, 4, 2, false}, + {"library_committee_member", "图书委员", 1, 4, 3, false}, + {"health_committee_member", "保健委员", 1, 4, 2, false}, + {"young_lady", "大小姐", 1, 4, 3, false}, + {"newspaper_club", "新闻部", 1, 4, 3, false}, + {"go_home_club", "归宅部", 0, 5, 3, false}, + {"infected_person", "感染者", 0, 2, 1, false}, + {"accomplice", "共犯", 0, 3, 1, false}, + {"culprit", "犯人", 0, 3, 1, true}, + {"alien", "外星人", -1, 1, 1, false}, +} + +type RemoveRule struct { + TypeID string + Count int +} + +type GameSetting struct { + PlayerCount int + HarmonyTarget int + RemoveCards []RemoveRule + TotalCards int + HandSize int +} + +var GameSettings = map[int]GameSetting{ + 3: {3, 9, []RemoveRule{ + {"library_committee_member", 1}, {"accomplice", 1}, {"honor_student", 1}, + {"discipline_committee_member", 1}, {"young_lady", 1}, {"newspaper_club", 1}, {"go_home_club", 1}, + }, 18, 6}, + 4: {4, 8, []RemoveRule{{"library_committee_member", 1}}, 24, 6}, + 5: {5, 7, nil, 25, 5}, + 6: {6, 6, []RemoveRule{{"library_committee_member", 1}}, 24, 4}, +} + +func BuildDeck(playerCount int) ([]*Card, GameSetting) { + setting := GameSettings[playerCount] + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + removeCount := make(map[string]int) + for _, r := range setting.RemoveCards { + removeCount[r.TypeID] += r.Count + } + + var deck []*Card + uid := 0 + for _, def := range AllCardDefs { + toRemove := removeCount[def.TypeID] + toAdd := def.Count - toRemove + for i := 0; i < toAdd; i++ { + uid++ + deck = append(deck, &Card{ + UID: fmt.Sprintf("c_%03d", uid), + TypeID: def.TypeID, + Name: def.Name, + MP: def.MP, + }) + } + } + + rng.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) + return deck, setting +} + +func IsUnusable(typeID string) bool { + return typeID == "culprit" +} + +func GetCardDef(typeID string) *CardDef { + for i := range AllCardDefs { + if AllCardDefs[i].TypeID == typeID { + return &AllCardDefs[i] + } + } + return nil +} diff --git a/game/effects.go b/game/effects.go new file mode 100644 index 0000000..6e8cdae --- /dev/null +++ b/game/effects.go @@ -0,0 +1,719 @@ +package game + +import "fmt" + +type EffectState struct { + Active bool + CardTypeID string + CasterID string + Step int + WaitingFor string + ChoiceType string + ChoiceData map[string]interface{} + TempData map[string]interface{} + PendingResponses map[string]interface{} + AllCollected bool +} + +func (e *Engine) triggerEffect(caster *PlayerState, card *Card) bool { + switch card.TypeID { + case "student_council_president", "culprit", "alien": + return false + case "library_committee_member": + return e.effectLibrary(caster) + case "discipline_committee_member": + return e.effectDiscipline(caster) + case "health_committee_member": + return e.effectHealth(caster) + case "go_home_club": + return e.effectGoHome(caster) + case "accomplice": + return e.effectAccomplice(caster) + case "class_monitor": + return e.effectClassMonitor(caster) + case "young_lady": + return e.effectYoungLady(caster) + case "newspaper_club": + return e.effectNewspaper(caster) + case "honor_student": + return e.effectHonorStudent(caster) + case "infected_person": + return e.effectInfected(caster) + } + return false +} + +func (e *Engine) effectLibrary(caster *PlayerState) bool { + if len(e.HarmonyZone) == 0 { + return false + } + var cards []map[string]interface{} + for _, c := range e.HarmonyZone { + cards = append(cards, map[string]interface{}{ + "uid": c.UID, "type_id": c.TypeID, "name": c.Name, "mp": c.MP, + }) + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "library_committee_member", CasterID: caster.ID, + WaitingFor: caster.ID, ChoiceType: "view_cards", Step: 0, + ChoiceData: map[string]interface{}{"cards": cards, "message": "调和区的卡牌"}, + } + return true +} + +func (e *Engine) effectDiscipline(caster *PlayerState) bool { + var targets []map[string]interface{} + for _, p := range e.Players { + if p.ID != caster.ID && !p.IsExited && len(p.Hand) > 0 { + targets = append(targets, map[string]interface{}{"player_id": p.ID, "nickname": p.Nickname}) + } + } + if len(targets) == 0 { + return false + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "discipline_committee_member", CasterID: caster.ID, + WaitingFor: caster.ID, ChoiceType: "select_player", Step: 0, + ChoiceData: map[string]interface{}{"targets": targets, "message": "选择要查看手牌的玩家"}, + TempData: make(map[string]interface{}), + } + return true +} + +func (e *Engine) effectHealth(caster *PlayerState) bool { + var cards []map[string]interface{} + for _, p := range e.Players { + for _, c := range p.SkillZone { + if c.TypeID != "health_committee_member" { + cards = append(cards, map[string]interface{}{ + "uid": c.UID, "type_id": c.TypeID, "name": c.Name, "owner_id": p.ID, + }) + } + } + } + if len(cards) == 0 { + return false + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "health_committee_member", CasterID: caster.ID, + WaitingFor: caster.ID, ChoiceType: "select_skill_card", Step: 0, + ChoiceData: map[string]interface{}{"cards": cards, "message": "选择一张已使用的牌加入手牌"}, + } + return true +} + +func (e *Engine) effectGoHome(caster *PlayerState) bool { + if len(e.HarmonyZone) == 0 || len(caster.Hand) == 0 { + return false + } + var handCards []map[string]interface{} + for _, c := range caster.Hand { + handCards = append(handCards, map[string]interface{}{"uid": c.UID, "type_id": c.TypeID, "name": c.Name}) + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "go_home_club", CasterID: caster.ID, + WaitingFor: caster.ID, ChoiceType: "select_card_from_hand", Step: 0, + ChoiceData: map[string]interface{}{"cards": handCards, "message": "选择一张手牌与调和区交换"}, + TempData: make(map[string]interface{}), + } + return true +} + +func (e *Engine) effectAccomplice(caster *PlayerState) bool { + var cards []map[string]interface{} + for _, p := range e.Players { + for _, c := range p.ChallengeZone { + cards = append(cards, map[string]interface{}{ + "uid": c.UID, "owner_id": p.ID, + }) + } + } + if len(cards) == 0 { + return false + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "accomplice", CasterID: caster.ID, + WaitingFor: caster.ID, ChoiceType: "select_challenge_card", Step: 0, + ChoiceData: map[string]interface{}{"cards": cards, "message": "选择一张质疑区的牌"}, + TempData: make(map[string]interface{}), + } + return true +} + +func (e *Engine) effectClassMonitor(caster *PlayerState) bool { + var targets []map[string]interface{} + for _, p := range e.Players { + if !p.IsExited && len(p.Hand) > 0 { + targets = append(targets, map[string]interface{}{"player_id": p.ID, "nickname": p.Nickname}) + } + } + if len(targets) < 2 { + return false + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "class_monitor", CasterID: caster.ID, + WaitingFor: caster.ID, ChoiceType: "select_player", Step: 0, + ChoiceData: map[string]interface{}{"targets": targets, "message": "选择换牌的第一名玩家(可以是自己)"}, + TempData: make(map[string]interface{}), + } + return true +} + +func (e *Engine) effectYoungLady(caster *PlayerState) bool { + var targets []map[string]interface{} + for _, p := range e.Players { + if p.ID != caster.ID && !p.IsExited && len(p.Hand) > 0 { + targets = append(targets, map[string]interface{}{"player_id": p.ID, "nickname": p.Nickname}) + } + } + if len(targets) == 0 { + return false + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "young_lady", CasterID: caster.ID, + WaitingFor: caster.ID, ChoiceType: "select_player", Step: 0, + ChoiceData: map[string]interface{}{"targets": targets, "message": "选择一名玩家抽取手牌"}, + TempData: make(map[string]interface{}), + } + return true +} + +func (e *Engine) effectNewspaper(caster *PlayerState) bool { + e.Effect = &EffectState{ + Active: true, CardTypeID: "newspaper_club", CasterID: caster.ID, + ChoiceType: "all_select_card", Step: 0, + ChoiceData: map[string]interface{}{"message": "选择一张手牌传给左手边的玩家"}, + PendingResponses: make(map[string]interface{}), + } + for _, p := range e.Players { + if !p.IsExited && len(p.Hand) > 0 { + e.Effect.WaitingFor = p.ID + } + } + return true +} + +func (e *Engine) effectHonorStudent(caster *PlayerState) bool { + var culpritHolder, alienHolder string + for _, p := range e.Players { + if p.ID == caster.ID { + continue + } + for _, c := range p.Hand { + if c.TypeID == "culprit" { + culpritHolder = p.ID + } + if c.TypeID == "alien" { + alienHolder = p.ID + } + } + } + + e.Effect = &EffectState{ + Active: true, CardTypeID: "honor_student", CasterID: caster.ID, + TempData: map[string]interface{}{ + "culprit_holder": culpritHolder, + "alien_holder": alienHolder, + }, + } + + if culpritHolder != "" { + cp := e.GetPlayer(culpritHolder) + if cp != nil && !cp.IsExited { + e.Effect.Step = 0 + e.Effect.WaitingFor = culpritHolder + e.Effect.ChoiceType = "select_option" + e.Effect.ChoiceData = map[string]interface{}{ + "message": "有人使用了优等生!你是犯人,必须吐出舌头。", + "options": []map[string]interface{}{ + {"value": "long", "label": "吐长舌头"}, + {"value": "short", "label": "吐短舌头"}, + }, + } + return true + } + } + + if alienHolder != "" { + ap := e.GetPlayer(alienHolder) + if ap != nil && !ap.IsExited { + e.Effect.Step = 1 + e.Effect.WaitingFor = alienHolder + e.Effect.ChoiceType = "select_option" + e.Effect.ChoiceData = map[string]interface{}{ + "message": "有人使用了优等生!你是外星人,是否要装作犯人吐舌头?", + "options": []map[string]interface{}{ + {"value": "long", "label": "吐长舌头"}, + {"value": "short", "label": "吐短舌头"}, + {"value": "none", "label": "不吐舌头"}, + }, + } + return true + } + } + + e.Effect.Step = 99 + e.Effect.WaitingFor = caster.ID + e.Effect.ChoiceType = "view_cards" + e.Effect.ChoiceData = map[string]interface{}{"message": "优等生效果:没有人吐舌头"} + return true +} + +func (e *Engine) effectInfected(caster *PlayerState) bool { + if len(caster.Hand) <= 1 { + return false + } + caster.InfectedActive = true + return false +} + +// HandleEffectResponse processes a player's response during effect resolution +func (e *Engine) HandleEffectResponse(playerID string, response map[string]interface{}) error { + ef := e.Effect + if ef == nil || !ef.Active { + return fmt.Errorf("no active effect") + } + + switch ef.CardTypeID { + case "library_committee_member", "student_council_president": + return e.finishEffect() + case "discipline_committee_member": + return e.respondDiscipline(playerID, response) + case "health_committee_member": + return e.respondHealth(playerID, response) + case "go_home_club": + return e.respondGoHome(playerID, response) + case "accomplice": + return e.respondAccomplice(playerID, response) + case "class_monitor": + return e.respondClassMonitor(playerID, response) + case "young_lady": + return e.respondYoungLady(playerID, response) + case "newspaper_club": + return e.respondNewspaper(playerID, response) + case "honor_student": + return e.respondHonorStudent(playerID, response) + case "infected_person": + return e.respondInfected(playerID, response) + } + return e.finishEffect() +} + +func (e *Engine) respondDiscipline(pid string, resp map[string]interface{}) error { + ef := e.Effect + if ef.Step == 0 { + tid, _ := resp["player_id"].(string) + target := e.GetPlayer(tid) + if target == nil { + return fmt.Errorf("invalid target") + } + var cards []map[string]interface{} + for _, c := range target.Hand { + cards = append(cards, map[string]interface{}{"uid": c.UID, "type_id": c.TypeID, "name": c.Name, "mp": c.MP}) + } + ef.Step = 1 + ef.ChoiceType = "view_cards" + ef.ChoiceData = map[string]interface{}{"cards": cards, "message": target.Nickname + " 的手牌"} + return nil + } + return e.finishEffect() +} + +func (e *Engine) respondHealth(pid string, resp map[string]interface{}) error { + uid, _ := resp["card_uid"].(string) + for _, p := range e.Players { + for i, c := range p.SkillZone { + if c.UID == uid && c.TypeID != "health_committee_member" { + p.SkillZone = append(p.SkillZone[:i], p.SkillZone[i+1:]...) + caster := e.GetPlayer(e.Effect.CasterID) + caster.Hand = append(caster.Hand, c) + return e.finishEffect() + } + } + } + return fmt.Errorf("card not found") +} + +func (e *Engine) respondGoHome(pid string, resp map[string]interface{}) error { + ef := e.Effect + if ef.Step == 0 { + uid, _ := resp["card_uid"].(string) + ef.TempData["hand_card_uid"] = uid + ef.Step = 1 + var hCards []map[string]interface{} + for i, c := range e.HarmonyZone { + hCards = append(hCards, map[string]interface{}{"uid": c.UID, "index": i}) + } + ef.ChoiceType = "select_harmony_card" + ef.ChoiceData = map[string]interface{}{"cards": hCards, "message": "选择调和区的一张牌"} + return nil + } + hUID, _ := resp["card_uid"].(string) + handUID, _ := ef.TempData["hand_card_uid"].(string) + caster := e.GetPlayer(ef.CasterID) + + var handCard *Card + for i, c := range caster.Hand { + if c.UID == handUID { + handCard = c + caster.Hand = append(caster.Hand[:i], caster.Hand[i+1:]...) + break + } + } + var harmonyCard *Card + for i, c := range e.HarmonyZone { + if c.UID == hUID { + harmonyCard = c + e.HarmonyZone = append(e.HarmonyZone[:i], e.HarmonyZone[i+1:]...) + break + } + } + if handCard != nil { + e.HarmonyZone = append(e.HarmonyZone, handCard) + } + if harmonyCard != nil { + caster.Hand = append(caster.Hand, harmonyCard) + } + return e.finishEffect() +} + +func (e *Engine) respondAccomplice(pid string, resp map[string]interface{}) error { + ef := e.Effect + if ef.Step == 0 { + uid, _ := resp["card_uid"].(string) + ef.TempData["challenge_uid"] = uid + owner, _ := resp["owner_id"].(string) + ef.TempData["challenge_owner"] = owner + ef.Step = 1 + var targets []map[string]interface{} + for _, p := range e.Players { + if p.ID != owner { + targets = append(targets, map[string]interface{}{"player_id": p.ID, "nickname": p.Nickname}) + } + } + ef.ChoiceType = "select_player" + ef.ChoiceData = map[string]interface{}{"targets": targets, "message": "选择转移到哪个玩家面前"} + return nil + } + targetID, _ := resp["player_id"].(string) + cUID, _ := ef.TempData["challenge_uid"].(string) + ownerID, _ := ef.TempData["challenge_owner"].(string) + + owner := e.GetPlayer(ownerID) + target := e.GetPlayer(targetID) + if owner == nil || target == nil { + return e.finishEffect() + } + for i, c := range owner.ChallengeZone { + if c.UID == cUID { + owner.ChallengeZone = append(owner.ChallengeZone[:i], owner.ChallengeZone[i+1:]...) + target.ChallengeZone = append(target.ChallengeZone, c) + break + } + } + return e.finishEffect() +} + +func (e *Engine) respondClassMonitor(pid string, resp map[string]interface{}) error { + ef := e.Effect + if ef.Step == 0 { + aid, _ := resp["player_id"].(string) + ef.TempData["player_a_id"] = aid + ef.Step = 1 + var targets []map[string]interface{} + for _, p := range e.Players { + if p.ID != aid && !p.IsExited && len(p.Hand) > 0 { + targets = append(targets, map[string]interface{}{"player_id": p.ID, "nickname": p.Nickname}) + } + } + ef.WaitingFor = ef.CasterID + ef.ChoiceType = "select_player" + ef.ChoiceData = map[string]interface{}{"targets": targets, "message": "选择换牌的第二名玩家"} + return nil + } + if ef.Step == 1 { + bid, _ := resp["player_id"].(string) + ef.TempData["player_b_id"] = bid + ef.Step = 2 + aid, _ := ef.TempData["player_a_id"].(string) + playerA := e.GetPlayer(aid) + ef.WaitingFor = aid + var cards []map[string]interface{} + for _, c := range playerA.Hand { + cards = append(cards, map[string]interface{}{"uid": c.UID, "type_id": c.TypeID, "name": c.Name}) + } + ef.ChoiceType = "select_card_from_hand" + ef.ChoiceData = map[string]interface{}{"cards": cards, "message": playerA.Nickname + ":选择一张手牌交给对方"} + return nil + } + if ef.Step == 2 { + uid, _ := resp["card_uid"].(string) + ef.TempData["card_a_uid"] = uid + ef.Step = 3 + bid, _ := ef.TempData["player_b_id"].(string) + playerB := e.GetPlayer(bid) + ef.WaitingFor = bid + var cards []map[string]interface{} + for _, c := range playerB.Hand { + cards = append(cards, map[string]interface{}{"uid": c.UID, "type_id": c.TypeID, "name": c.Name}) + } + ef.ChoiceType = "select_card_from_hand" + ef.ChoiceData = map[string]interface{}{"cards": cards, "message": playerB.Nickname + ":选择一张手牌交给对方"} + return nil + } + bUID, _ := resp["card_uid"].(string) + aUID, _ := ef.TempData["card_a_uid"].(string) + aid, _ := ef.TempData["player_a_id"].(string) + bid, _ := ef.TempData["player_b_id"].(string) + pA := e.GetPlayer(aid) + pB := e.GetPlayer(bid) + var cA, cB *Card + for i, c := range pA.Hand { + if c.UID == aUID { + cA = c + pA.Hand = append(pA.Hand[:i], pA.Hand[i+1:]...) + break + } + } + for i, c := range pB.Hand { + if c.UID == bUID { + cB = c + pB.Hand = append(pB.Hand[:i], pB.Hand[i+1:]...) + break + } + } + if cA != nil { + pB.Hand = append(pB.Hand, cA) + } + if cB != nil { + pA.Hand = append(pA.Hand, cB) + } + return e.finishEffect() +} + +func (e *Engine) respondYoungLady(pid string, resp map[string]interface{}) error { + ef := e.Effect + caster := e.GetPlayer(ef.CasterID) + if ef.Step == 0 { + tid, _ := resp["player_id"].(string) + target := e.GetPlayer(tid) + if target == nil || len(target.Hand) == 0 { + return e.finishEffect() + } + drawn := target.Hand[e.rng.Intn(len(target.Hand))] + e.removeFromHand(target, drawn.UID) + caster.Hand = append(caster.Hand, drawn) + + ef.TempData["target_id"] = tid + ef.Step = 1 + ef.WaitingFor = ef.CasterID + var cards []map[string]interface{} + for _, c := range caster.Hand { + cards = append(cards, map[string]interface{}{"uid": c.UID, "type_id": c.TypeID, "name": c.Name}) + } + ef.ChoiceType = "select_card_from_hand" + ef.ChoiceData = map[string]interface{}{ + "cards": cards, + "message": "从 " + target.Nickname + " 抽到了「" + drawn.Name + "」,选择一张手牌反面返还", + } + return nil + } + uid, _ := resp["card_uid"].(string) + tid, _ := ef.TempData["target_id"].(string) + target := e.GetPlayer(tid) + for i, c := range caster.Hand { + if c.UID == uid { + caster.Hand = append(caster.Hand[:i], caster.Hand[i+1:]...) + if target != nil { + target.Hand = append(target.Hand, c) + } + break + } + } + return e.finishEffect() +} + +func (e *Engine) respondNewspaper(pid string, resp map[string]interface{}) error { + ef := e.Effect + uid, _ := resp["card_uid"].(string) + ef.PendingResponses[pid] = uid + + allDone := true + for _, p := range e.Players { + if !p.IsExited && len(p.Hand) > 0 { + if _, ok := ef.PendingResponses[p.ID]; !ok { + allDone = false + break + } + } + } + if !allDone { + return nil + } + + type passInfo struct { + from *PlayerState + card *Card + } + var passes []passInfo + activePlayers := e.getActivePlayers() + for _, p := range activePlayers { + uidStr, ok := ef.PendingResponses[p.ID].(string) + if !ok { + continue + } + for _, c := range p.Hand { + if c.UID == uidStr { + passes = append(passes, passInfo{from: p, card: c}) + break + } + } + } + + for _, pi := range passes { + e.removeFromHand(pi.from, pi.card.UID) + } + for i, pi := range passes { + nextIdx := (i + 1) % len(activePlayers) + activePlayers[nextIdx].Hand = append(activePlayers[nextIdx].Hand, pi.card) + } + + return e.finishEffect() +} + +func (e *Engine) respondHonorStudent(pid string, resp map[string]interface{}) error { + ef := e.Effect + + if ef.Step == 0 { + tongue, _ := resp["value"].(string) + culpritID, _ := ef.TempData["culprit_holder"].(string) + cp := e.GetPlayer(culpritID) + name := culpritID + if cp != nil { + name = cp.Nickname + } + tongues := []interface{}{map[string]interface{}{"nickname": name, "tongue": tongue}} + ef.TempData["tongues"] = tongues + + alienHolder, _ := ef.TempData["alien_holder"].(string) + if alienHolder != "" { + ap := e.GetPlayer(alienHolder) + if ap != nil && !ap.IsExited { + ef.Step = 1 + ef.WaitingFor = alienHolder + ef.ChoiceType = "select_option" + ef.ChoiceData = map[string]interface{}{ + "message": "有人使用了优等生!你是外星人,是否要装作犯人吐舌头?", + "options": []interface{}{ + map[string]interface{}{"value": "long", "label": "吐长舌头"}, + map[string]interface{}{"value": "short", "label": "吐短舌头"}, + map[string]interface{}{"value": "none", "label": "不吐舌头"}, + }, + } + return nil + } + } + return e.showHonorResult() + } + + if ef.Step == 1 { + tongue, _ := resp["value"].(string) + if tongue != "none" { + alienHolder, _ := ef.TempData["alien_holder"].(string) + ap := e.GetPlayer(alienHolder) + name := alienHolder + if ap != nil { + name = ap.Nickname + } + tongues, _ := ef.TempData["tongues"].([]interface{}) + tongues = append(tongues, map[string]interface{}{"nickname": name, "tongue": tongue}) + ef.TempData["tongues"] = tongues + } + return e.showHonorResult() + } + + return e.finishEffect() +} + +func (e *Engine) showHonorResult() error { + ef := e.Effect + tongues, _ := ef.TempData["tongues"].([]interface{}) + msg := fmt.Sprintf("优等生效果:%d人吐了舌头\n", len(tongues)) + for _, t := range tongues { + tm, _ := t.(map[string]interface{}) + nick, _ := tm["nickname"].(string) + tongue, _ := tm["tongue"].(string) + label := "长舌头" + if tongue == "short" { + label = "短舌头" + } + msg += fmt.Sprintf(" • %s — %s\n", nick, label) + } + ef.Step = 99 + ef.WaitingFor = ef.CasterID + ef.ChoiceType = "view_cards" + ef.ChoiceData = map[string]interface{}{"message": msg, "tongues": tongues} + return nil +} + +func (e *Engine) finishEffect() error { + e.Effect = nil + caster := e.GetPlayer(e.Players[e.CurrentTurn].ID) + e.finishTurn(caster) + return nil +} + +func (e *Engine) getActivePlayers() []*PlayerState { + var active []*PlayerState + for _, p := range e.Players { + if !p.IsExited && len(p.Hand) > 0 { + active = append(active, p) + } + } + return active +} + +func (e *Engine) CheckInfectedEffect() bool { + p := e.CurrentPlayer() + if p == nil || !p.InfectedActive { + return false + } + + still := false + for _, c := range p.SkillZone { + if c.TypeID == "infected_person" { + still = true + break + } + } + if !still || len(e.HarmonyZone) == 0 { + p.InfectedActive = false + return false + } + + p.InfectedActive = false + var hCards []map[string]interface{} + for _, c := range e.HarmonyZone { + hCards = append(hCards, map[string]interface{}{"uid": c.UID}) + } + e.Effect = &EffectState{ + Active: true, CardTypeID: "infected_person", CasterID: p.ID, + WaitingFor: p.ID, ChoiceType: "select_harmony_card", Step: 0, + ChoiceData: map[string]interface{}{"cards": hCards, "message": "感染者效果:选择调和区的一张牌加入手牌"}, + } + return true +} + +func (e *Engine) respondInfected(pid string, resp map[string]interface{}) error { + uid, _ := resp["card_uid"].(string) + caster := e.GetPlayer(e.Effect.CasterID) + for i, c := range e.HarmonyZone { + if c.UID == uid { + e.HarmonyZone = append(e.HarmonyZone[:i], e.HarmonyZone[i+1:]...) + caster.Hand = append(caster.Hand, c) + break + } + } + e.Effect = nil + return nil +} diff --git a/game/engine.go b/game/engine.go new file mode 100644 index 0000000..477631f --- /dev/null +++ b/game/engine.go @@ -0,0 +1,349 @@ +package game + +import ( + "errors" + "fmt" + "math/rand" + "time" +) + +type PlayerState struct { + ID string + Nickname string + IsBot bool + Hand []*Card + SkillZone []*Card + ChallengeZone []*Card + IsExited bool + InfectedActive bool +} + +type Engine struct { + Players []*PlayerState + HarmonyZone []*Card + CurrentTurn int + Phase string + TurnPhase string + HarmonyTarget int + Setting GameSetting + SelectedCard *Card + SelectedBy string + Effect *EffectState + rng *rand.Rand +} + +func NewEngine(playerIDs []string, nicknames []string, botFlags []bool, playerCount int) *Engine { + e := &Engine{ + Phase: "dealing", + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } + for i, pid := range playerIDs { + e.Players = append(e.Players, &PlayerState{ + ID: pid, Nickname: nicknames[i], IsBot: botFlags[i], + }) + } + return e +} + +func (e *Engine) Deal() { + deck, setting := BuildDeck(len(e.Players)) + e.Setting = setting + e.HarmonyTarget = setting.HarmonyTarget + idx := 0 + for idx < len(deck) { + for _, p := range e.Players { + if idx >= len(deck) { + break + } + p.Hand = append(p.Hand, deck[idx]) + idx++ + } + } + e.CurrentTurn = e.findStartingPlayer() + e.Phase = "playing" + e.TurnPhase = "select_card" + e.startTurn() +} + +func (e *Engine) findStartingPlayer() int { + for i, p := range e.Players { + for _, c := range p.Hand { + if c.TypeID == "student_council_president" { + return i + } + } + } + return e.rng.Intn(len(e.Players)) +} + +func (e *Engine) startTurn() { + if e.CheckInfectedEffect() { + e.TurnPhase = "effect_resolving" + } else { + e.TurnPhase = "select_card" + } +} + +func (e *Engine) CurrentPlayer() *PlayerState { + if e.CurrentTurn < 0 || e.CurrentTurn >= len(e.Players) { + return nil + } + return e.Players[e.CurrentTurn] +} + +func (e *Engine) GetPlayer(id string) *PlayerState { + for _, p := range e.Players { + if p.ID == id { + return p + } + } + return nil +} + +func (e *Engine) SelectCard(playerID, cardUID string) error { + if e.Phase != "playing" || e.TurnPhase != "select_card" { + return errors.New("cannot select card now") + } + cp := e.CurrentPlayer() + if cp == nil || cp.ID != playerID { + return errors.New("not your turn") + } + for _, c := range cp.Hand { + if c.UID == cardUID { + if IsUnusable(c.TypeID) { + return fmt.Errorf("「%s」无法被使用", c.Name) + } + e.SelectedCard = c + e.SelectedBy = playerID + e.TurnPhase = "select_action" + return nil + } + } + return errors.New("card not in hand") +} + +func (e *Engine) ConfirmAction(playerID, action string, targetPlayerID string) error { + if e.Phase != "playing" { + return errors.New("not playing") + } + cp := e.CurrentPlayer() + if cp == nil || cp.ID != playerID { + return errors.New("not your turn") + } + if e.SelectedCard == nil { + return errors.New("no card selected") + } + switch action { + case "skill": + return e.doSkill(cp) + case "harmony": + return e.doHarmony(cp) + case "challenge": + if e.TurnPhase == "select_action" && targetPlayerID == "" { + e.TurnPhase = "select_target" + return nil + } + return e.doChallenge(cp, targetPlayerID) + } + return errors.New("unknown action") +} + +func (e *Engine) doSkill(p *PlayerState) error { + card := e.SelectedCard + e.removeFromHand(p, card.UID) + p.SkillZone = append(p.SkillZone, card) + e.SelectedCard = nil + e.SelectedBy = "" + + if e.triggerEffect(p, card) { + e.TurnPhase = "effect_resolving" + return nil + } + e.finishTurn(p) + return nil +} + +func (e *Engine) doHarmony(p *PlayerState) error { + card := e.SelectedCard + e.removeFromHand(p, card.UID) + e.HarmonyZone = append(e.HarmonyZone, card) + e.SelectedCard = nil + e.SelectedBy = "" + e.finishTurn(p) + return nil +} + +func (e *Engine) doChallenge(p *PlayerState, targetID string) error { + if targetID == "" || targetID == p.ID { + return errors.New("invalid challenge target") + } + target := e.GetPlayer(targetID) + if target == nil { + return errors.New("target not found") + } + card := e.SelectedCard + e.removeFromHand(p, card.UID) + target.ChallengeZone = append(target.ChallengeZone, card) + e.SelectedCard = nil + e.SelectedBy = "" + e.finishTurn(p) + return nil +} + +func (e *Engine) finishTurn(p *PlayerState) { + if len(p.Hand) <= 1 { + p.IsExited = true + } + if e.allExited() { + e.Phase = "all_exited" + e.TurnPhase = "" + return + } + e.advanceTurn() + e.startTurn() +} + +func (e *Engine) advanceTurn() { + n := len(e.Players) + next := (e.CurrentTurn + 1) % n + for i := 0; i < n; i++ { + if !e.Players[next].IsExited { + e.CurrentTurn = next + return + } + next = (next + 1) % n + } +} + +func (e *Engine) allExited() bool { + for _, p := range e.Players { + if !p.IsExited { + return false + } + } + return true +} + +func (e *Engine) removeFromHand(p *PlayerState, uid string) { + for i, c := range p.Hand { + if c.UID == uid { + p.Hand = append(p.Hand[:i], p.Hand[i+1:]...) + return + } + } +} + +func (e *Engine) IsEffectResolving() bool { + return e.Effect != nil && e.Effect.Active +} + +func (e *Engine) EffectNeedsResponseFrom(playerID string) bool { + if !e.IsEffectResolving() { + return false + } + ef := e.Effect + if ef.ChoiceType == "all_select_card" { + _, done := ef.PendingResponses[playerID] + p := e.GetPlayer(playerID) + return !done && p != nil && !p.IsExited && len(p.Hand) > 0 + } + return ef.WaitingFor == playerID +} + +func (e *Engine) BotAutoPlay(playerID string) (string, string) { + p := e.GetPlayer(playerID) + if p == nil || !p.IsBot || p.IsExited { + return "", "" + } + var playable []*Card + for _, c := range p.Hand { + if !IsUnusable(c.TypeID) { + playable = append(playable, c) + } + } + if len(playable) == 0 { + return "", "" + } + card := playable[e.rng.Intn(len(playable))] + e.SelectedCard = card + e.SelectedBy = playerID + + roll := e.rng.Intn(10) + if roll < 4 { + return "harmony", "" + } else if roll < 7 { + return "skill", "" + } else { + var targets []string + for _, o := range e.Players { + if o.ID != playerID && !o.IsExited { + targets = append(targets, o.ID) + } + } + if len(targets) == 0 { + return "harmony", "" + } + return "challenge", targets[e.rng.Intn(len(targets))] + } +} + +func (e *Engine) BotAutoEffectResponse(playerID string) map[string]interface{} { + ef := e.Effect + if ef == nil || !ef.Active { + return nil + } + p := e.GetPlayer(playerID) + if p == nil { + return nil + } + + resp := make(map[string]interface{}) + + switch ef.ChoiceType { + case "view_cards": + resp["ok"] = true + case "select_player": + if targets, ok := ef.ChoiceData["targets"].([]map[string]interface{}); ok && len(targets) > 0 { + resp["player_id"] = targets[e.rng.Intn(len(targets))]["player_id"] + } + case "select_card_from_hand", "all_select_card": + if len(p.Hand) > 0 { + playable := p.Hand + var filtered []*Card + for _, c := range playable { + if !IsUnusable(c.TypeID) { + filtered = append(filtered, c) + } + } + if len(filtered) > 0 { + playable = filtered + } + resp["card_uid"] = playable[e.rng.Intn(len(playable))].UID + } + case "select_skill_card": + if cards, ok := ef.ChoiceData["cards"].([]map[string]interface{}); ok && len(cards) > 0 { + resp["card_uid"] = cards[e.rng.Intn(len(cards))]["uid"] + } + case "select_challenge_card": + if cards, ok := ef.ChoiceData["cards"].([]map[string]interface{}); ok && len(cards) > 0 { + c := cards[e.rng.Intn(len(cards))] + resp["card_uid"] = c["uid"] + resp["owner_id"] = c["owner_id"] + } + case "select_harmony_card": + if cards, ok := ef.ChoiceData["cards"].([]map[string]interface{}); ok && len(cards) > 0 { + resp["card_uid"] = cards[e.rng.Intn(len(cards))]["uid"] + } + case "yes_no": + resp["yes"] = e.rng.Intn(2) == 0 + case "select_option": + if opts, ok := ef.ChoiceData["options"].([]map[string]interface{}); ok && len(opts) > 0 { + resp["value"] = opts[e.rng.Intn(len(opts))]["value"] + } else if opts2, ok := ef.ChoiceData["options"].([]interface{}); ok && len(opts2) > 0 { + if m, ok := opts2[e.rng.Intn(len(opts2))].(map[string]interface{}); ok { + resp["value"] = m["value"] + } + } + } + return resp +} diff --git a/game/settlement.go b/game/settlement.go new file mode 100644 index 0000000..55d6155 --- /dev/null +++ b/game/settlement.go @@ -0,0 +1,189 @@ +package game + +type victoryCheck struct { + ID string + Nickname string + CardType string + CardName string + Priority int + Met bool +} + +func (e *Engine) RunSettlement() map[string]interface{} { + harmony := e.settleHarmony() + challenge := e.settleChallenge() + harmonyOK := harmony["success"].(bool) + imprisoned := challenge["imprisoned"].([]string) + victory := e.settleVictory(harmonyOK, imprisoned) + return map[string]interface{}{ + "harmony": harmony, "challenge": challenge, "victory": victory, + } +} + +func (e *Engine) settleHarmony() map[string]interface{} { + var cards []map[string]interface{} + total := 0 + for _, c := range e.HarmonyZone { + cards = append(cards, map[string]interface{}{ + "uid": c.UID, "type_id": c.TypeID, "name": c.Name, "mp": c.MP, + }) + total += c.MP + } + return map[string]interface{}{ + "cards": cards, "total_mp": total, "target": e.HarmonyTarget, + "success": total >= e.HarmonyTarget, + } +} + +func (e *Engine) settleChallenge() map[string]interface{} { + var perPlayer []map[string]interface{} + maxMP := 0 + for _, p := range e.Players { + var cards []map[string]interface{} + total := 0 + for _, c := range p.ChallengeZone { + eff := c.MP + if eff < 0 { + eff = 0 + } + total += eff + cards = append(cards, map[string]interface{}{ + "uid": c.UID, "type_id": c.TypeID, "name": c.Name, "mp": c.MP, "effective_mp": eff, + }) + } + perPlayer = append(perPlayer, map[string]interface{}{ + "player_id": p.ID, "nickname": p.Nickname, "cards": cards, "total_mp": total, + }) + if total > maxMP { + maxMP = total + } + } + + allSame := true + for _, pp := range perPlayer { + if pp["total_mp"].(int) != perPlayer[0]["total_mp"].(int) { + allSame = false + break + } + } + var imprisoned []string + if !allSame && maxMP > 0 { + for _, pp := range perPlayer { + if pp["total_mp"].(int) == maxMP { + imprisoned = append(imprisoned, pp["player_id"].(string)) + } + } + } + return map[string]interface{}{ + "per_player": perPlayer, "max_mp": maxMP, "imprisoned": imprisoned, + } +} + +func (e *Engine) settleVictory(harmonyOK bool, imprisoned []string) map[string]interface{} { + isJailed := func(pid string) bool { + for _, id := range imprisoned { + if id == pid { + return true + } + } + return false + } + + var all []victoryCheck + for _, p := range e.Players { + if len(p.Hand) == 0 { + continue + } + last := p.Hand[0] + def := GetCardDef(last.TypeID) + if def == nil { + continue + } + all = append(all, victoryCheck{ + ID: p.ID, Nickname: p.Nickname, + CardType: last.TypeID, CardName: last.Name, Priority: def.Priority, + }) + } + + culpritWins := false + + for pri := 1; pri <= 5; pri++ { + var group []int + for i, vc := range all { + if vc.Priority == pri { + group = append(group, i) + } + } + if len(group) == 0 { + continue + } + + for _, i := range group { + switch all[i].CardType { + case "culprit": + all[i].Met = !isJailed(all[i].ID) + if all[i].Met { + culpritWins = true + } + } + } + for _, i := range group { + switch all[i].CardType { + case "alien": + all[i].Met = isJailed(all[i].ID) + case "infected_person": + all[i].Met = !harmonyOK + case "accomplice": + all[i].Met = culpritWins + case "go_home_club": + all[i].Met = true + case "culprit": + // already set above + default: + all[i].Met = harmonyOK + } + } + + anyWin := false + for _, i := range group { + if all[i].Met { + anyWin = true + break + } + } + if !anyWin { + continue + } + + var winners []map[string]interface{} + for _, i := range group { + if all[i].Met { + winners = append(winners, vcToMap(all[i])) + } + } + return map[string]interface{}{ + "checks": allToMaps(all), "winners": winners, + "winning_priority": pri, "end_type": "normal", + } + } + + return map[string]interface{}{ + "checks": allToMaps(all), "winners": []map[string]interface{}{}, "end_type": "all_dead", + } +} + +func vcToMap(vc victoryCheck) map[string]interface{} { + return map[string]interface{}{ + "player_id": vc.ID, "nickname": vc.Nickname, + "card_type": vc.CardType, "card_name": vc.CardName, + "priority": vc.Priority, "met": vc.Met, + } +} + +func allToMaps(all []victoryCheck) []map[string]interface{} { + var r []map[string]interface{} + for _, vc := range all { + r = append(r, vcToMap(vc)) + } + return r +} diff --git a/game/snapshot.go b/game/snapshot.go new file mode 100644 index 0000000..3cc4273 --- /dev/null +++ b/game/snapshot.go @@ -0,0 +1,77 @@ +package game + +func (e *Engine) Snapshot(forPlayerID string) map[string]interface{} { + cp := e.CurrentPlayer() + currentTurnID := "" + if cp != nil { + currentTurnID = cp.ID + } + + snap := map[string]interface{}{ + "phase": e.Phase, + "turn_phase": e.TurnPhase, + "current_turn": currentTurnID, + "your_player_id": forPlayerID, + "harmony_target": e.HarmonyTarget, + } + + var players []map[string]interface{} + for _, p := range e.Players { + pi := map[string]interface{}{ + "player_id": p.ID, + "nickname": p.Nickname, + "is_bot": p.IsBot, + "is_exited": p.IsExited, + "hand_card_count": len(p.Hand), + "challenge_zone_count": len(p.ChallengeZone), + } + var skill []map[string]interface{} + for _, c := range p.SkillZone { + skill = append(skill, map[string]interface{}{ + "uid": c.UID, "type_id": c.TypeID, "name": c.Name, "mp": c.MP, + }) + } + pi["skill_zone"] = skill + players = append(players, pi) + } + snap["players"] = players + + me := e.GetPlayer(forPlayerID) + var hand []map[string]interface{} + if me != nil { + for _, c := range me.Hand { + hand = append(hand, map[string]interface{}{ + "uid": c.UID, "type_id": c.TypeID, "name": c.Name, "mp": c.MP, + }) + } + } + snap["hand_cards"] = hand + + var harmony []map[string]interface{} + for _, c := range e.HarmonyZone { + harmony = append(harmony, map[string]interface{}{ + "uid": c.UID, "face_up": false, + }) + } + snap["harmony_zone"] = harmony + + if e.Effect != nil && e.Effect.Active { + ef := e.Effect + effectInfo := map[string]interface{}{ + "active": true, + "card_type": ef.CardTypeID, + "caster_id": ef.CasterID, + } + if e.EffectNeedsResponseFrom(forPlayerID) { + effectInfo["needs_response"] = true + effectInfo["choice_type"] = ef.ChoiceType + effectInfo["choice_data"] = ef.ChoiceData + } else { + effectInfo["needs_response"] = false + effectInfo["waiting_message"] = "等待效果结算中..." + } + snap["effect"] = effectInfo + } + + return snap +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0618892 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module Embalming_Girl_Server + +go 1.22 + +require github.com/gorilla/websocket v1.5.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/handler/ws.go b/handler/ws.go new file mode 100644 index 0000000..65655ee --- /dev/null +++ b/handler/ws.go @@ -0,0 +1,626 @@ +package handler + +import ( + "Embalming_Girl_Server/game" + "Embalming_Girl_Server/room" + "encoding/json" + "log" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +type Client struct { + ID string + RoomID string + Conn *websocket.Conn + Send chan []byte + mu sync.Mutex +} + +type Hub struct { + RoomMgr *room.Manager + clients map[string]*Client + engines map[string]*game.Engine + mu sync.RWMutex +} + +func NewHub() *Hub { + return &Hub{ + RoomMgr: room.NewManager(), + clients: make(map[string]*Client), + engines: make(map[string]*game.Engine), + } +} + +func (h *Hub) InitTestRooms() { + for _, n := range []int{3, 4, 5, 6} { + rid := "TEST" + string(rune('0'+n)) + h.RoomMgr.CreateTestRoom(rid, n) + log.Printf("Test room: %s (%d players, %d bots)", rid, n, n-1) + } +} + +func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Println("Upgrade:", err) + return + } + clientID := generateClientID() + client := &Client{ID: clientID, Conn: conn, Send: make(chan []byte, 256)} + h.mu.Lock() + h.clients[clientID] = client + h.mu.Unlock() + log.Printf("Connected: %s", clientID) + go h.writePump(client) + go h.readPump(client) +} + +func (h *Hub) readPump(c *Client) { + defer func() { h.removeClient(c); c.Conn.Close() }() + for { + msgType, msg, err := c.Conn.ReadMessage() + if err != nil { + break + } + if msgType == websocket.BinaryMessage { + h.relayAudio(c, msg) + continue + } + var m map[string]interface{} + if json.Unmarshal(msg, &m) == nil { + h.handleMessage(c, m) + } + } +} + +func (h *Hub) writePump(c *Client) { + defer c.Conn.Close() + for data := range c.Send { + c.mu.Lock() + err := c.Conn.WriteMessage(websocket.TextMessage, data) + c.mu.Unlock() + if err != nil { + break + } + } +} + +func (h *Hub) removeClient(c *Client) { + h.mu.Lock() + delete(h.clients, c.ID) + h.mu.Unlock() + if c.RoomID != "" { + h.RoomMgr.LeaveRoom(c.RoomID, c.ID) + rm := h.RoomMgr.GetRoom(c.RoomID) + if rm != nil { + h.broadcastRoomState(rm) + } + } + close(c.Send) + log.Printf("Disconnected: %s", c.ID) +} + +func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) { + t, _ := msg["type"].(string) + p, _ := msg["payload"].(map[string]interface{}) + switch t { + case "create_room": + h.handleCreateRoom(c, p) + case "join_room": + h.handleJoinRoom(c, p) + case "leave_room": + h.handleLeaveRoom(c) + case "set_ready": + h.handleSetReady(c, p) + case "start_game": + h.handleStartGame(c) + case "list_rooms": + h.handleListRooms(c) + case "select_card": + h.handleSelectCard(c, p) + case "confirm_action": + h.handleConfirmAction(c, p) + case "effect_response": + h.handleEffectResponse(c, p) + case "send_chat": + h.handleChat(c, p) + case "heartbeat": + h.sendTo(c, "heartbeat_ack", nil) + } +} + +// ---- Room handlers ---- + +func (h *Hub) handleListRooms(c *Client) { + h.sendTo(c, "room_list", map[string]interface{}{"rooms": h.RoomMgr.ListRooms()}) +} + +func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) { + nick, _ := p["nickname"].(string) + mp := 4 + if v, ok := p["max_players"].(float64); ok { + mp = int(v) + } + if nick == "" { + nick = "Player" + } + rm := h.RoomMgr.CreateRoom(c.ID, nick, mp) + c.RoomID = rm.ID + rm.Players[c.ID].Send = c.Send + h.sendTo(c, "room_created", map[string]interface{}{"room_id": rm.ID}) + h.broadcastRoomState(rm) +} + +func (h *Hub) handleJoinRoom(c *Client, p map[string]interface{}) { + rid, _ := p["room_id"].(string) + nick, _ := p["nickname"].(string) + if nick == "" { + nick = "Player" + } + rm, ok := h.RoomMgr.JoinRoom(rid, c.ID, nick) + if !ok { + h.sendTo(c, "error", map[string]interface{}{"message": "Cannot join room"}) + return + } + c.RoomID = rm.ID + rm.Players[c.ID].Send = c.Send + h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID}) + h.broadcastRoomState(rm) +} + +func (h *Hub) handleLeaveRoom(c *Client) { + if c.RoomID == "" { + return + } + rid := c.RoomID + h.RoomMgr.LeaveRoom(rid, c.ID) + c.RoomID = "" + h.sendTo(c, "room_left", nil) + if rm := h.RoomMgr.GetRoom(rid); rm != nil { + h.broadcastRoomState(rm) + } +} + +func (h *Hub) handleChat(c *Client, p map[string]interface{}) { + if c.RoomID == "" { + return + } + rm := h.RoomMgr.GetRoom(c.RoomID) + if rm == nil { + return + } + text, _ := p["text"].(string) + if text == "" { + return + } + nick := "" + if pl, ok := rm.Players[c.ID]; ok { + nick = pl.Nickname + } + msg := map[string]interface{}{ + "sender_id": c.ID, "nickname": nick, "text": text, + } + for _, pid := range rm.PlayerOrder { + pl := rm.Players[pid] + if pl.IsBot || pl.Send == nil { + continue + } + h.sendToChannel(pl.Send, "chat", msg) + } +} + +func (h *Hub) handleSetReady(c *Client, p map[string]interface{}) { + ready, _ := p["ready"].(bool) + if c.RoomID == "" { + return + } + h.RoomMgr.SetReady(c.RoomID, c.ID, ready) + if rm := h.RoomMgr.GetRoom(c.RoomID); rm != nil { + h.broadcastRoomState(rm) + } +} + +// ---- Game start ---- + +func (h *Hub) handleStartGame(c *Client) { + if c.RoomID == "" { + return + } + rm := h.RoomMgr.GetRoom(c.RoomID) + if rm == nil || !rm.Players[c.ID].IsHost { + h.sendTo(c, "error", map[string]interface{}{"message": "Only host can start"}) + return + } + if len(rm.Players) < 3 { + h.sendTo(c, "error", map[string]interface{}{"message": "Need 3+ players"}) + return + } + + var ids, nicks []string + var bots []bool + for _, pid := range rm.PlayerOrder { + p := rm.Players[pid] + ids = append(ids, p.ID) + nicks = append(nicks, p.Nickname) + bots = append(bots, p.IsBot) + } + + eng := game.NewEngine(ids, nicks, bots, len(ids)) + eng.Deal() + + h.mu.Lock() + h.engines[rm.ID] = eng + h.mu.Unlock() + + rm.State = "playing" + rm.HarmonyTarget = eng.HarmonyTarget + + for _, pid := range rm.PlayerOrder { + p := rm.Players[pid] + if p.IsBot || p.Send == nil { + continue + } + h.sendToChannel(p.Send, "game_start", map[string]interface{}{ + "your_player_id": pid, + "harmony_target": eng.HarmonyTarget, + "player_count": len(ids), + }) + } + + log.Printf("Game started in %s, %d players, target=%d, first=%s", + rm.ID, len(ids), eng.HarmonyTarget, eng.CurrentPlayer().ID) + + h.broadcastSnapshots(rm.ID) + h.processBotTurns(rm.ID) +} + +// ---- Game action handlers ---- + +func (h *Hub) handleSelectCard(c *Client, p map[string]interface{}) { + h.mu.RLock() + eng := h.engines[c.RoomID] + h.mu.RUnlock() + if eng == nil { + return + } + + uid, _ := p["card_uid"].(string) + if err := eng.SelectCard(c.ID, uid); err != nil { + h.sendTo(c, "error", map[string]interface{}{"message": err.Error()}) + return + } + h.broadcastSnapshots(c.RoomID) +} + +func (h *Hub) handleConfirmAction(c *Client, p map[string]interface{}) { + h.mu.RLock() + eng := h.engines[c.RoomID] + h.mu.RUnlock() + if eng == nil { + return + } + + action, _ := p["action"].(string) + target, _ := p["target_player_id"].(string) + + if err := eng.ConfirmAction(c.ID, action, target); err != nil { + h.sendTo(c, "error", map[string]interface{}{"message": err.Error()}) + return + } + + h.broadcastSnapshots(c.RoomID) + + if eng.IsEffectResolving() { + h.processBotEffectResponses(c.RoomID) + return + } + + if eng.Phase == "all_exited" { + h.handleAllExited(c.RoomID) + return + } + + h.processBotTurns(c.RoomID) +} + +func (h *Hub) handleEffectResponse(c *Client, p map[string]interface{}) { + h.mu.RLock() + eng := h.engines[c.RoomID] + h.mu.RUnlock() + if eng == nil || !eng.IsEffectResolving() { + return + } + + if err := eng.HandleEffectResponse(c.ID, p); err != nil { + h.sendTo(c, "error", map[string]interface{}{"message": err.Error()}) + return + } + + h.broadcastSnapshots(c.RoomID) + + if eng.IsEffectResolving() { + h.processBotEffectResponses(c.RoomID) + return + } + + if eng.Phase == "all_exited" { + h.handleAllExited(c.RoomID) + return + } + + h.processBotTurns(c.RoomID) +} + +func (h *Hub) processBotEffectResponses(roomID string) { + h.mu.RLock() + eng := h.engines[roomID] + h.mu.RUnlock() + if eng == nil { + return + } + + for eng.IsEffectResolving() { + ef := eng.Effect + if ef == nil { + break + } + + if ef.ChoiceType == "all_select_card" { + anyBotLeft := false + for _, p := range eng.Players { + if p.IsBot && eng.EffectNeedsResponseFrom(p.ID) { + time.Sleep(200 * time.Millisecond) + resp := eng.BotAutoEffectResponse(p.ID) + if resp != nil { + eng.HandleEffectResponse(p.ID, resp) + } + anyBotLeft = true + } + } + if !anyBotLeft { + break + } + h.broadcastSnapshots(roomID) + continue + } + + waitFor := ef.WaitingFor + bot := eng.GetPlayer(waitFor) + if bot == nil || !bot.IsBot { + break + } + + time.Sleep(200 * time.Millisecond) + resp := eng.BotAutoEffectResponse(waitFor) + if resp != nil { + eng.HandleEffectResponse(waitFor, resp) + } + h.broadcastSnapshots(roomID) + } +} + +func (h *Hub) processBotTurns(roomID string) { + h.mu.RLock() + eng := h.engines[roomID] + h.mu.RUnlock() + if eng == nil { + return + } + + for eng.Phase == "playing" && !eng.IsEffectResolving() { + cp := eng.CurrentPlayer() + if cp == nil || !cp.IsBot { + break + } + + time.Sleep(300 * time.Millisecond) + + action, target := eng.BotAutoPlay(cp.ID) + if action == "" { + break + } + + eng.ConfirmAction(cp.ID, action, target) + h.broadcastSnapshots(roomID) + + if eng.IsEffectResolving() { + h.processBotEffectResponses(roomID) + if eng.IsEffectResolving() { + break + } + h.broadcastSnapshots(roomID) + } + + if eng.Phase == "all_exited" { + h.handleAllExited(roomID) + return + } + } +} + +func (h *Hub) handleAllExited(roomID string) { + rm := h.RoomMgr.GetRoom(roomID) + if rm == nil { + return + } + h.mu.RLock() + eng := h.engines[roomID] + h.mu.RUnlock() + if eng == nil { + return + } + + result := eng.RunSettlement() + log.Printf("Settlement in room %s: %s", roomID, result["victory"].(map[string]interface{})["end_type"]) + + for _, pid := range rm.PlayerOrder { + p := rm.Players[pid] + if p.IsBot || p.Send == nil { + continue + } + h.sendToChannel(p.Send, "settlement_result", result) + } + + endType, _ := result["victory"].(map[string]interface{})["end_type"].(string) + winners, _ := result["victory"].(map[string]interface{})["winners"].([]map[string]interface{}) + var winnerNames []string + for _, w := range winners { + winnerNames = append(winnerNames, w["nickname"].(string)) + } + + endMsg := map[string]interface{}{"end_type": endType} + if endType == "all_dead" { + endMsg["message"] = "全灭结局 — 没有任何人获胜" + } else { + msg := "" + for i, n := range winnerNames { + if i > 0 { + msg += ", " + } + msg += n + } + endMsg["message"] = "获胜者: " + msg + endMsg["winners"] = winners + } + + for _, pid := range rm.PlayerOrder { + p := rm.Players[pid] + if p.IsBot || p.Send == nil { + continue + } + h.sendToChannel(p.Send, "game_end", endMsg) + } + + rm.State = "waiting" + for _, p := range rm.Players { + p.IsReady = false + if p.IsBot { + p.IsReady = true + } + } + h.mu.Lock() + delete(h.engines, roomID) + h.mu.Unlock() + h.broadcastRoomState(rm) +} + +// ---- Audio relay ---- + +func (h *Hub) relayAudio(sender *Client, pcm []byte) { + if sender.RoomID == "" { + return + } + rm := h.RoomMgr.GetRoom(sender.RoomID) + if rm == nil { + return + } + + idBytes := []byte(sender.ID) + header := make([]byte, 2+len(idBytes)) + header[0] = byte(len(idBytes) >> 8) + header[1] = byte(len(idBytes)) + copy(header[2:], idBytes) + frame := append(header, pcm...) + + h.mu.RLock() + defer h.mu.RUnlock() + for _, pid := range rm.PlayerOrder { + p := rm.Players[pid] + if p.IsBot || pid == sender.ID { + continue + } + if cl, ok := h.clients[pid]; ok { + cl.mu.Lock() + cl.Conn.WriteMessage(websocket.BinaryMessage, frame) + cl.mu.Unlock() + } + } +} + +// ---- Broadcast helpers ---- + +func (h *Hub) broadcastSnapshots(roomID string) { + rm := h.RoomMgr.GetRoom(roomID) + if rm == nil { + return + } + h.mu.RLock() + eng := h.engines[roomID] + h.mu.RUnlock() + if eng == nil { + return + } + + for _, pid := range rm.PlayerOrder { + p := rm.Players[pid] + if p.IsBot || p.Send == nil { + continue + } + snap := eng.Snapshot(pid) + data := buildMessage("state_snapshot", snap) + select { + case p.Send <- data: + default: + } + } +} + +func (h *Hub) broadcastRoomState(rm *room.Room) { + players := rm.GetPlayerInfos() + for _, pid := range rm.PlayerOrder { + p, ok := rm.Players[pid] + if !ok || p.IsBot || p.Send == nil { + continue + } + st := map[string]interface{}{ + "room_id": rm.ID, "state": rm.State, + "harmony_target": rm.HarmonyTarget, + "players": players, "your_player_id": pid, + } + select { + case p.Send <- buildMessage("room_state", st): + default: + } + } +} + +func (h *Hub) sendTo(c *Client, t string, p map[string]interface{}) { + select { + case c.Send <- buildMessage(t, p): + default: + } +} + +func (h *Hub) sendToChannel(ch chan []byte, t string, p map[string]interface{}) { + select { + case ch <- buildMessage(t, p): + default: + } +} + +func buildMessage(t string, p map[string]interface{}) []byte { + m := map[string]interface{}{"type": t} + if p != nil { + m["payload"] = p + } + d, _ := json.Marshal(m) + return d +} + +func generateClientID() string { + const cs = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 12) + for i := range b { + b[i] = cs[rand.Intn(len(cs))] + } + return string(b) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..a5be766 --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "Embalming_Girl_Server/handler" + "log" + "net/http" +) + +func main() { + hub := handler.NewHub() + hub.InitTestRooms() + + http.HandleFunc("/ws", hub.ServeWS) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte("Embalming Girl Server v0.1.0")) + }) + + addr := ":8080" + log.Printf("Server started on %s", addr) + log.Printf("Test rooms: TEST3, TEST4, TEST5, TEST6") + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatal("ListenAndServe:", err) + } +} diff --git a/model/message.go b/model/message.go new file mode 100644 index 0000000..31678b1 --- /dev/null +++ b/model/message.go @@ -0,0 +1,23 @@ +package model + +type Message struct { + Type string `json:"type"` + Seq int `json:"seq,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Payload map[string]interface{} `json:"payload,omitempty"` +} + +type PlayerInfo struct { + PlayerID string `json:"player_id"` + Nickname string `json:"nickname"` + IsReady bool `json:"is_ready"` + IsHost bool `json:"is_host"` +} + +type RoomState struct { + RoomID string `json:"room_id"` + State string `json:"state"` + HarmonyTarget int `json:"harmony_target"` + Players []PlayerInfo `json:"players"` + YourPlayerID string `json:"your_player_id"` +} diff --git a/room/manager.go b/room/manager.go new file mode 100644 index 0000000..d00eb37 --- /dev/null +++ b/room/manager.go @@ -0,0 +1,310 @@ +package room + +import ( + "fmt" + "math/rand" + "sync" + "time" +) + +type Player struct { + ID string + Nickname string + IsReady bool + IsHost bool + IsBot bool + Send chan []byte +} + +type Room struct { + ID string + State string + MaxPlayers int + HarmonyTarget int + ChaosMode bool + Players map[string]*Player + PlayerOrder []string + mu sync.RWMutex +} + +type Manager struct { + rooms map[string]*Room + mu sync.RWMutex +} + +func NewManager() *Manager { + return &Manager{ + rooms: make(map[string]*Room), + } +} + +func (m *Manager) CreateTestRoom(roomID string, playerCount int) *Room { + m.mu.Lock() + defer m.mu.Unlock() + + rm := &Room{ + ID: roomID, + State: "waiting", + MaxPlayers: playerCount, + Players: make(map[string]*Player), + } + + botNames := []string{"Bot-A", "Bot-B", "Bot-C", "Bot-D", "Bot-E"} + for i := 0; i < playerCount-1; i++ { + botID := fmt.Sprintf("bot_%s_%d", roomID, i) + bot := &Player{ + ID: botID, + Nickname: botNames[i%len(botNames)], + IsReady: true, + IsBot: true, + Send: nil, + } + rm.Players[botID] = bot + rm.PlayerOrder = append(rm.PlayerOrder, botID) + } + + m.rooms[roomID] = rm + return rm +} + +func (m *Manager) CreateRoom(hostID, nickname string, maxPlayers int) *Room { + m.mu.Lock() + defer m.mu.Unlock() + + roomID := generateRoomID() + room := &Room{ + ID: roomID, + State: "waiting", + MaxPlayers: maxPlayers, + Players: make(map[string]*Player), + } + + host := &Player{ + ID: hostID, + Nickname: nickname, + IsHost: true, + Send: make(chan []byte, 256), + } + room.Players[hostID] = host + room.PlayerOrder = append(room.PlayerOrder, hostID) + + m.rooms[roomID] = room + return room +} + +func (m *Manager) GetRoom(roomID string) *Room { + m.mu.RLock() + defer m.mu.RUnlock() + return m.rooms[roomID] +} + +func (m *Manager) ListRooms() []map[string]interface{} { + m.mu.RLock() + defer m.mu.RUnlock() + + var list []map[string]interface{} + for _, rm := range m.rooms { + rm.mu.RLock() + realCount := 0 + totalCount := len(rm.Players) + for _, p := range rm.Players { + if !p.IsBot { + realCount++ + } + } + rm.mu.RUnlock() + list = append(list, map[string]interface{}{ + "room_id": rm.ID, + "state": rm.State, + "player_count": totalCount, + "real_players": realCount, + "max_players": rm.MaxPlayers, + }) + } + return list +} + +func (m *Manager) JoinRoom(roomID, playerID, nickname string) (*Room, bool) { + m.mu.RLock() + room, exists := m.rooms[roomID] + m.mu.RUnlock() + + if !exists { + return nil, false + } + + room.mu.Lock() + defer room.mu.Unlock() + + if len(room.Players) >= room.MaxPlayers { + return nil, false + } + if room.State != "waiting" { + return nil, false + } + + player := &Player{ + ID: playerID, + Nickname: nickname, + Send: make(chan []byte, 256), + } + room.Players[playerID] = player + room.PlayerOrder = append(room.PlayerOrder, playerID) + + hasRealHost := false + for _, p := range room.Players { + if p.IsHost && !p.IsBot { + hasRealHost = true + break + } + } + if !hasRealHost { + for _, p := range room.Players { + p.IsHost = false + } + player.IsHost = true + } + + return room, true +} + +func (m *Manager) LeaveRoom(roomID, playerID string) { + m.mu.Lock() + defer m.mu.Unlock() + + room, exists := m.rooms[roomID] + if !exists { + return + } + + room.mu.Lock() + defer room.mu.Unlock() + + delete(room.Players, playerID) + + newOrder := make([]string, 0) + for _, id := range room.PlayerOrder { + if id != playerID { + newOrder = append(newOrder, id) + } + } + room.PlayerOrder = newOrder + + realCount := 0 + for _, p := range room.Players { + if !p.IsBot { + realCount++ + } + } + if realCount == 0 && !isTestRoom(roomID) { + delete(m.rooms, roomID) + return + } + + if realCount == 0 && isTestRoom(roomID) { + for _, p := range room.Players { + p.IsHost = false + } + room.State = "waiting" + return + } + + hasHost := false + for _, p := range room.Players { + if p.IsHost && !p.IsBot { + hasHost = true + break + } + } + if !hasHost { + for _, p := range room.Players { + p.IsHost = false + } + for _, id := range room.PlayerOrder { + p := room.Players[id] + if !p.IsBot { + p.IsHost = true + break + } + } + } +} + +func isTestRoom(roomID string) bool { + return roomID == "TEST3" || roomID == "TEST4" || roomID == "TEST5" || roomID == "TEST6" +} + +func (m *Manager) SetReady(roomID, playerID string, ready bool) bool { + room := m.GetRoom(roomID) + if room == nil { + return false + } + room.mu.Lock() + defer room.mu.Unlock() + p, exists := room.Players[playerID] + if !exists { + return false + } + p.IsReady = ready + return true +} + +func (r *Room) GetPlayerInfos() []map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + + var infos []map[string]interface{} + for _, id := range r.PlayerOrder { + p, ok := r.Players[id] + if !ok { + continue + } + infos = append(infos, map[string]interface{}{ + "player_id": p.ID, + "nickname": p.Nickname, + "is_ready": p.IsReady, + "is_host": p.IsHost, + "is_bot": p.IsBot, + }) + } + return infos +} + +func (r *Room) BroadcastToReal(data []byte) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, p := range r.Players { + if p.IsBot || p.Send == nil { + continue + } + select { + case p.Send <- data: + default: + } + } +} + +func (r *Room) SendToPlayer(playerID string, data []byte) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.Players[playerID] + if !ok || p.IsBot || p.Send == nil { + return + } + select { + case p.Send <- data: + default: + } +} + +func generateRoomID() string { + src := rand.NewSource(time.Now().UnixNano()) + r := rand.New(src) + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, 6) + for i := range b { + b[i] = charset[r.Intn(len(charset))] + } + return string(b) +} diff --git a/server.exe b/server.exe new file mode 100644 index 0000000..935fbac Binary files /dev/null and b/server.exe differ