From 873d68363c251aba265e4720db9a0db4e0cfcf65 Mon Sep 17 00:00:00 2001 From: xxyheaven <1433191064@qq.com> Date: Sun, 7 Apr 2024 00:21:15 +0800 Subject: [PATCH 1/4] fix bug (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、给carditem增加dragging参数; 2、filterskill在mute的情况下不播放技能特效; 3、给ActiveSkill增加no_indicate参数 4、回合开始时、结束时这两个时机当前回合角色的phase设置为Player.None 5、给usecard增加noIndicate参数 --- Fk/RoomElement/CardItem.qml | 2 ++ lua/core/engine.lua | 10 +++++----- lua/core/skill.lua | 2 ++ lua/fk_ex.lua | 1 + lua/server/events/gameflow.lua | 7 ++++--- lua/server/events/usecard.lua | 30 +++++++++++++++++------------- lua/server/room.lua | 4 +++- lua/server/system_enum.lua | 1 + 8 files changed, 35 insertions(+), 22 deletions(-) diff --git a/Fk/RoomElement/CardItem.qml b/Fk/RoomElement/CardItem.qml index e4cd94533..d00a51a65 100644 --- a/Fk/RoomElement/CardItem.qml +++ b/Fk/RoomElement/CardItem.qml @@ -62,6 +62,7 @@ Item { property alias goBackAnim: goBackAnimation property int goBackDuration: 500 property bool busy: false // whether there is a running emotion on the card + property alias dragging: drag.active signal toggleDiscards() signal clicked() @@ -278,6 +279,7 @@ Item { } DragHandler { + id: drag enabled: draggable grabPermissions: PointHandler.TakeOverForbidden xAxis.enabled: true diff --git a/lua/core/engine.lua b/lua/core/engine.lua index b30237642..6a09076a4 100644 --- a/lua/core/engine.lua +++ b/lua/core/engine.lua @@ -602,12 +602,12 @@ function Engine:filterCard(id, player, data) if modify and RoomInstance then if not f.mute then player:broadcastSkillInvoke(f.name) + RoomInstance:doAnimate("InvokeSkill", { + name = f.name, + player = player.id, + skill_type = f.anim_type, + }) end - RoomInstance:doAnimate("InvokeSkill", { - name = f.name, - player = player.id, - skill_type = f.anim_type, - }) RoomInstance:sendLog{ type = "#FilterCard", arg = f.name, diff --git a/lua/core/skill.lua b/lua/core/skill.lua index c03405e2e..f2e21ebfd 100644 --- a/lua/core/skill.lua +++ b/lua/core/skill.lua @@ -9,6 +9,7 @@ ---@field public frequency Frequency @ 技能发动的频繁程度,通常compulsory(锁定技)及limited(限定技)用的多。 ---@field public visible boolean @ 技能是否会显示在游戏中 ---@field public mute boolean @ 决定是否关闭技能配音 +---@field public no_indicate boolean @ 决定是否关闭技能指示线 ---@field public global boolean @ 决定是否是全局技能 ---@field public anim_type string @ 技能类型定义 ---@field public related_skills Skill[] @ 和本技能相关的其他技能,有时候一个技能实际上是通过好几个技能拼接而实现的。 @@ -41,6 +42,7 @@ function Skill:initialize(name, frequency) self.lordSkill = false self.cardSkill = false self.mute = false + self.no_indicate = false self.anim_type = "" self.related_skills = {} self.attachedKingdom = {} diff --git a/lua/fk_ex.lua b/lua/fk_ex.lua index b5ce40cc8..12dc2d1e1 100644 --- a/lua/fk_ex.lua +++ b/lua/fk_ex.lua @@ -27,6 +27,7 @@ _, Weapon, Armor, DefensiveRide, OffensiveRide, Treasure = table.unpack(Equip) local function readCommonSpecToSkill(skill, spec) skill.mute = spec.mute + skill.no_indicate = spec.no_indicate skill.anim_type = spec.anim_type if spec.attached_equip then diff --git a/lua/server/events/gameflow.lua b/lua/server/events/gameflow.lua index cec12d99e..3fd79be7a 100644 --- a/lua/server/events/gameflow.lua +++ b/lua/server/events/gameflow.lua @@ -226,9 +226,8 @@ end GameEvent.functions[GameEvent.Turn] = function(self) local room = self.room - local logic = room.logic - - logic:trigger(fk.TurnStart, room.current) + room.current.phase = Player.PhaseNone + room.logic:trigger(fk.TurnStart, room.current) room.current:play() end @@ -251,8 +250,10 @@ GameEvent.cleaners[GameEvent.Turn] = function(self) current.skipped_phases = {} end + current.phase = Player.PhaseNone logic:trigger(fk.TurnEnd, current, nil, self.interrupted) logic:trigger(fk.AfterTurnEnd, current, nil, self.interrupted) + current.phase = Player.NotActive for _, p in ipairs(room.players) do p:setCardUseHistory("", 0, Player.HistoryTurn) diff --git a/lua/server/events/usecard.lua b/lua/server/events/usecard.lua index e1e0a0a84..7796b0807 100644 --- a/lua/server/events/usecard.lua +++ b/lua/server/events/usecard.lua @@ -48,20 +48,23 @@ local sendCardEmotionAndLog = function(room, cardUseEvent) ---[[ if not _card:isVirtual() then local temp = { card = _card } - Fk:filterCard(_card.id, room:getPlayerById(from), temp) + Fk:filterCard(_card.id, room:getCardOwner(_card), temp) card = temp.card end cardUseEvent.card = card --]] playCardEmotionAndSound(room, room:getPlayerById(from), card) - room:doAnimate("Indicate", { - from = from, - to = cardUseEvent.tos or Util.DummyTable, - }) + + if not cardUseEvent.noIndicate then + room:doAnimate("Indicate", { + from = from, + to = cardUseEvent.tos or Util.DummyTable, + }) + end local useCardIds = card:isVirtual() and card.subcards or { card.id } - if cardUseEvent.tos and #cardUseEvent.tos > 0 then + if cardUseEvent.tos and #cardUseEvent.tos > 0 and not cardUseEvent.noIndicate then local to = {} for _, t in ipairs(cardUseEvent.tos) do table.insert(to, t[1]) @@ -182,18 +185,18 @@ GameEvent.functions[GameEvent.UseCard] = function(self) cardUseEvent.card.skill:onUse(room, cardUseEvent) end - local _card = sendCardEmotionAndLog(room, cardUseEvent) - if logic:trigger(fk.PreCardUse, room:getPlayerById(cardUseEvent.from), cardUseEvent) then logic:breakEvent() end + local _card = sendCardEmotionAndLog(room, cardUseEvent) + room:moveCardTo(cardUseEvent.card, Card.Processing, nil, fk.ReasonUse) local card = cardUseEvent.card local useCardIds = card:isVirtual() and card.subcards or { card.id } if #useCardIds > 0 then - if cardUseEvent.tos and #cardUseEvent.tos > 0 and #cardUseEvent.tos <= 2 then + if cardUseEvent.tos and #cardUseEvent.tos > 0 and #cardUseEvent.tos <= 2 and not cardUseEvent.noIndicate then local tos = table.map(cardUseEvent.tos, function(e) return e[1] end) room:sendFootnote(useCardIds, { type = "##UseCardTo", @@ -255,6 +258,11 @@ GameEvent.functions[GameEvent.RespondCard] = function(self) local cardResponseEvent = table.unpack(self.data) local room = self.room local logic = room.logic + + if logic:trigger(fk.PreCardRespond, room:getPlayerById(cardResponseEvent.from), cardResponseEvent) then + logic:breakEvent() + end + local from = cardResponseEvent.customFrom or cardResponseEvent.from local card = cardResponseEvent.card local cardIds = room:getSubcardsByRule(card) @@ -284,10 +292,6 @@ GameEvent.functions[GameEvent.RespondCard] = function(self) playCardEmotionAndSound(room, room:getPlayerById(from), card) - if logic:trigger(fk.PreCardRespond, room:getPlayerById(cardResponseEvent.from), cardResponseEvent) then - logic:breakEvent() - end - room:moveCardTo(card, Card.Processing, nil, fk.ReasonResonpse) if #cardIds > 0 then room:sendFootnote(cardIds, { diff --git a/lua/server/room.lua b/lua/server/room.lua index 508f5c7f0..600937cb0 100644 --- a/lua/server/room.lua +++ b/lua/server/room.lua @@ -2083,7 +2083,9 @@ function Room:handleUseCardReply(player, data) if skill.interaction then skill.interaction.data = data.interaction_data end if skill:isInstanceOf(ActiveSkill) then self:useSkill(player, skill, function() - self:doIndicate(player.id, targets) + if not skill.no_indicate then + self:doIndicate(player.id, targets) + end skill:onUse(self, { from = player.id, cards = selected_cards, diff --git a/lua/server/system_enum.lua b/lua/server/system_enum.lua index d77857ef0..55b244cd5 100644 --- a/lua/server/system_enum.lua +++ b/lua/server/system_enum.lua @@ -115,6 +115,7 @@ fk.IceDamage = 4 ---@field public prohibitedCardNames? string[] ---@field public damageDealt? table ---@field public additionalEffect? integer +---@field public noIndicate? boolean ---@class AimStruct ---@field public from integer From 1a4da186d2ed5d2aab5c268633d20845f46a986d Mon Sep 17 00:00:00 2001 From: notify Date: Sun, 7 Apr 2024 00:35:57 +0800 Subject: [PATCH 2/4] Hotfix (#340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端热更新功能 当调用upgrade命令后,会创建更加新的roomthread (!完全没测试过) --- Fk/Logic.js | 12 +-- Fk/Pages/Lobby.qml | 10 +- lang/zh_CN.ts | 4 + lua/client/i18n/zh_CN.lua | 1 + lua/server/request.lua | 2 +- lua/server/scheduler.lua | 10 ++ src/network/router.cpp | 131 +------------------------ src/server/room.cpp | 198 +++++++++++++++++++++++++++++++++++++- src/server/room.h | 7 ++ src/server/roomthread.cpp | 19 ++++ src/server/roomthread.h | 5 + src/server/server.cpp | 42 +++++++- src/server/server.h | 6 ++ src/server/shell.cpp | 57 +++++------ src/swig/server.i | 1 + 15 files changed, 333 insertions(+), 172 deletions(-) diff --git a/Fk/Logic.js b/Fk/Logic.js index 5e2ad89d2..4fef708c3 100644 --- a/Fk/Logic.js +++ b/Fk/Logic.js @@ -143,14 +143,12 @@ callbacks["UpdateRoomList"] = (jsonData) => { const current = mainStack.currentItem; // should be lobby if (mainStack.depth === 2) { current.roomModel.clear(); - JSON.parse(jsonData).forEach(function (room) { + JSON.parse(jsonData).forEach(room => { + const [roomId, roomName, gameMode, playerNum, capacity, hasPassword, + outdated] = room; current.roomModel.append({ - roomId: room[0], - roomName: room[1], - gameMode: room[2], - playerNum: room[3], - capacity: room[4], - hasPassword: room[5] ? true : false, + roomId, roomName, gameMode, playerNum, capacity, + hasPassword, outdated, }); }); } diff --git a/Fk/Pages/Lobby.qml b/Fk/Pages/Lobby.qml index 8da9162ce..9c5e071a4 100644 --- a/Fk/Pages/Lobby.qml +++ b/Fk/Pages/Lobby.qml @@ -64,7 +64,13 @@ Item { Text { horizontalAlignment: Text.AlignLeft Layout.fillWidth: true - text: roomName + text: { + let ret = roomName; + if (outdated) { + ret = '' + ret + ''; + } + return ret; + } font.pixelSize: 20 elide: Label.ElideRight } @@ -94,7 +100,7 @@ Item { text: (playerNum < capacity) ? luatr("Enter") : luatr("Observe") - enabled: !opTimer.running + enabled: !opTimer.running && !outdated onClicked: { opTimer.start(); diff --git a/lang/zh_CN.ts b/lang/zh_CN.ts index 371eae03b..f7c58d6d0 100644 --- a/lang/zh_CN.ts +++ b/lang/zh_CN.ts @@ -318,6 +318,10 @@ room password error 房间密码错误 + + room is outdated + 房间已过时 + no such room 房间不存在 diff --git a/lua/client/i18n/zh_CN.lua b/lua/client/i18n/zh_CN.lua index b7083b452..c0315d170 100644 --- a/lua/client/i18n/zh_CN.lua +++ b/lua/client/i18n/zh_CN.lua @@ -495,6 +495,7 @@ Fk:loadTranslationTable{ ["#ChainStateChange"] = "%from %arg 了武将牌", ["#ChainDamage"] = "%from 处于连环状态,将受到传导的伤害", ["#ChangeKingdom"] = "%from 的国籍从 %arg 变成了 %arg2", + ["#RoomOutdated"] = "服务器更新完毕!该房间已过期,将无法再次游玩", } -- card footnote diff --git a/lua/server/request.lua b/lua/server/request.lua index 1b9ff4190..66604a3b5 100644 --- a/lua/server/request.lua +++ b/lua/server/request.lua @@ -181,7 +181,7 @@ request_handlers["newroom"] = function(s, id) s:registerRoom(id) end -request_handlers["reloadpackage"] = function(room, id, reqlist) +request_handlers["reloadpackage"] = function(_, _, reqlist) if not IsConsoleStart() then return end local path = reqlist[3] Fk:reloadPackage(path) diff --git a/lua/server/scheduler.lua b/lua/server/scheduler.lua index e1bf22d3e..3df69ece2 100644 --- a/lua/server/scheduler.lua +++ b/lua/server/scheduler.lua @@ -22,6 +22,8 @@ end) -- 仿照Room接口编写的request协程处理器 local requestRoom = setmetatable({ + id = -1, + runningRooms = runningRooms, -- minDelayTime 是当没有任何就绪房间时,可以睡眠的时间。 -- 因为这个时间是所有房间预期就绪用时的最小值,故称为minDelayTime。 @@ -130,6 +132,14 @@ local function mainLoop() -- 调用RoomThread的trySleep函数开始真正的睡眠。会被wakeUp(c++)唤醒。 requestRoom.thread:trySleep(time) + local runningRoomsCount = -1 -- 必有requestRoom,从-1开始算 + for _ in pairs(runningRooms) do + runningRoomsCount = runningRoomsCount + 1 + if runningRoomsCount > 0 then break end + end + if runningRoomsCount == 0 and requestRoom.thread:isOutdated() then + break + end -- verbose('[!] Waked up after %f ms...', (os.getms() - cur) / 1000) diff --git a/src/network/router.cpp b/src/network/router.cpp index 4de18c85d..b0e6b4123 100644 --- a/src/network/router.cpp +++ b/src/network/router.cpp @@ -153,107 +153,6 @@ void Router::abortRequest() { } void Router::handlePacket(const QByteArray &rawPacket) { -#ifndef FK_CLIENT_ONLY - static QMap lobby_actions; - if (lobby_actions.size() <= 0) { - lobby_actions["UpdateAvatar"] = [](ServerPlayer *sender, - const QString &jsonData) { - auto arr = String2Json(jsonData).array(); - auto avatar = arr[0].toString(); - - if (CheckSqlString(avatar)) { - auto sql = QString("UPDATE userinfo SET avatar='%1' WHERE id=%2;") - .arg(avatar) - .arg(sender->getId()); - ExecSQL(ServerInstance->getDatabase(), sql); - sender->setAvatar(avatar); - sender->doNotify("UpdateAvatar", avatar); - } - }; - lobby_actions["UpdatePassword"] = [](ServerPlayer *sender, - const QString &jsonData) { - auto arr = String2Json(jsonData).array(); - auto oldpw = arr[0].toString(); - auto newpw = arr[1].toString(); - auto sql_find = - QString("SELECT password, salt FROM userinfo WHERE id=%1;") - .arg(sender->getId()); - - auto passed = false; - auto arr2 = SelectFromDatabase(ServerInstance->getDatabase(), sql_find); - auto result = arr2[0].toObject(); - passed = (result["password"].toString() == - QCryptographicHash::hash( - oldpw.append(result["salt"].toString()).toLatin1(), - QCryptographicHash::Sha256) - .toHex()); - if (passed) { - auto sql_update = - QString("UPDATE userinfo SET password='%1' WHERE id=%2;") - .arg(QCryptographicHash::hash( - newpw.append(result["salt"].toString()).toLatin1(), - QCryptographicHash::Sha256) - .toHex()) - .arg(sender->getId()); - ExecSQL(ServerInstance->getDatabase(), sql_update); - } - - sender->doNotify("UpdatePassword", passed ? "1" : "0"); - }; - lobby_actions["CreateRoom"] = [](ServerPlayer *sender, - const QString &jsonData) { - auto arr = String2Json(jsonData).array(); - auto name = arr[0].toString(); - auto capacity = arr[1].toInt(); - auto timeout = arr[2].toInt(); - auto settings = - QJsonDocument(arr[3].toObject()).toJson(QJsonDocument::Compact); - ServerInstance->createRoom(sender, name, capacity, timeout, settings); - }; - lobby_actions["EnterRoom"] = [](ServerPlayer *sender, - const QString &jsonData) { - auto arr = String2Json(jsonData).array(); - auto roomId = arr[0].toInt(); - auto room = ServerInstance->findRoom(roomId); - if (room) { - auto settings = QJsonDocument::fromJson(room->getSettings()); - auto password = settings["password"].toString(); - if (password.isEmpty() || arr[1].toString() == password) { - room->addPlayer(sender); - } else { - sender->doNotify("ErrorMsg", "room password error"); - } - } else { - sender->doNotify("ErrorMsg", "no such room"); - } - }; - lobby_actions["ObserveRoom"] = [](ServerPlayer *sender, - const QString &jsonData) { - auto arr = String2Json(jsonData).array(); - auto roomId = arr[0].toInt(); - auto room = ServerInstance->findRoom(roomId); - if (room) { - auto settings = QJsonDocument::fromJson(room->getSettings()); - auto password = settings["password"].toString(); - if (password.isEmpty() || arr[1].toString() == password) { - room->addObserver(sender); - } else { - sender->doNotify("ErrorMsg", "room password error"); - } - } else { - sender->doNotify("ErrorMsg", "no such room"); - } - }; - lobby_actions["Chat"] = [](ServerPlayer *sender, const QString &jsonData) { - sender->getRoom()->chat(sender, jsonData); - }; - lobby_actions["RefreshRoomList"] = [](ServerPlayer *sender, - const QString &jsonData) { - ServerInstance->updateRoomList(sender); - }; - } -#endif - QJsonDocument packet = QJsonDocument::fromJson(rawPacket); if (packet.isNull() || !packet.isArray()) return; @@ -278,35 +177,7 @@ void Router::handlePacket(const QByteArray &rawPacket) { } Room *room = player->getRoom(); - if (room->isLobby() && lobby_actions.contains(command)) - lobby_actions[command](player, jsonData); - else { - if (command == "QuitRoom") { - room->removePlayer(player); - } else if (command == "AddRobot") { - if (ServerInstance->getConfig("enableBots").toBool()) room->addRobot(player); - } else if (command == "KickPlayer") { - int i = jsonData.toInt(); - auto p = room->findPlayer(i); - if (p && !room->isStarted()) { - room->removePlayer(p); - room->addRejectId(i); - QTimer::singleShot(30000, this, [=]() { - room->removeRejectId(i); - }); - } - } else if (command == "Ready") { - player->setReady(!player->isReady()); - room->doBroadcastNotify(room->getPlayers(), "ReadyChanged", - QString("[%1,%2]").arg(player->getId()).arg(player->isReady())); - } else if (command == "StartGame") { - room->manuallyStart(); - } else if (command == "Chat") { - room->chat(player, jsonData); - } else if (command == "PushRequest") { - room->pushRequest(QString("%1,").arg(player->getId()) + jsonData); - } - } + room->handlePacket(player, command, jsonData); } #endif } else if (type & TYPE_REQUEST) { diff --git a/src/server/room.cpp b/src/server/room.cpp index 1a811c14b..9a27d8f32 100644 --- a/src/server/room.cpp +++ b/src/server/room.cpp @@ -22,7 +22,7 @@ Room::Room(RoomThread *m_thread) { id = server->nextRoomId; server->nextRoomId++; this->server = server; - this->m_thread = m_thread; + setThread(m_thread); if (m_thread) { // In case of lobby m_thread->addRoom(this); } @@ -60,7 +60,12 @@ Server *Room::getServer() const { return server; } RoomThread *Room::getThread() const { return m_thread; } -void Room::setThread(RoomThread *t) { m_thread = t; } +void Room::setThread(RoomThread *t) { + m_thread = t; + if (t != nullptr) { + md5 = t->getMd5(); + } +} int Room::getId() const { return id; } @@ -366,6 +371,12 @@ int Room::getTimeout() const { return timeout; } void Room::setTimeout(int timeout) { this->timeout = timeout; } +bool Room::isOutdated() { + bool ret = md5 != server->getMd5(); + if (ret) md5 = ""; + return ret; +} + bool Room::isStarted() const { return gameStarted; } void Room::doBroadcastNotify(const QList targets, @@ -616,3 +627,186 @@ void Room::addRejectId(int id) { void Room::removeRejectId(int id) { rejected_players.removeOne(id); } + +// ------------------------------------------------ +static void updateAvatar(ServerPlayer *sender, const QString &jsonData) { + auto arr = String2Json(jsonData).array(); + auto avatar = arr[0].toString(); + + if (CheckSqlString(avatar)) { + auto sql = QString("UPDATE userinfo SET avatar='%1' WHERE id=%2;") + .arg(avatar) + .arg(sender->getId()); + ExecSQL(ServerInstance->getDatabase(), sql); + sender->setAvatar(avatar); + sender->doNotify("UpdateAvatar", avatar); + } +} + +static void updatePassword(ServerPlayer *sender, const QString &jsonData) { + auto arr = String2Json(jsonData).array(); + auto oldpw = arr[0].toString(); + auto newpw = arr[1].toString(); + auto sql_find = + QString("SELECT password, salt FROM userinfo WHERE id=%1;") + .arg(sender->getId()); + + auto passed = false; + auto arr2 = SelectFromDatabase(ServerInstance->getDatabase(), sql_find); + auto result = arr2[0].toObject(); + passed = (result["password"].toString() == + QCryptographicHash::hash( + oldpw.append(result["salt"].toString()).toLatin1(), + QCryptographicHash::Sha256) + .toHex()); + if (passed) { + auto sql_update = + QString("UPDATE userinfo SET password='%1' WHERE id=%2;") + .arg(QCryptographicHash::hash( + newpw.append(result["salt"].toString()).toLatin1(), + QCryptographicHash::Sha256) + .toHex()) + .arg(sender->getId()); + ExecSQL(ServerInstance->getDatabase(), sql_update); + } + + sender->doNotify("UpdatePassword", passed ? "1" : "0"); +} + +static void createRoom(ServerPlayer *sender, const QString &jsonData) { + auto arr = String2Json(jsonData).array(); + auto name = arr[0].toString(); + auto capacity = arr[1].toInt(); + auto timeout = arr[2].toInt(); + auto settings = + QJsonDocument(arr[3].toObject()).toJson(QJsonDocument::Compact); + ServerInstance->createRoom(sender, name, capacity, timeout, settings); +} + +static void enterRoom(ServerPlayer *sender, const QString &jsonData) { + auto arr = String2Json(jsonData).array(); + auto roomId = arr[0].toInt(); + auto room = ServerInstance->findRoom(roomId); + if (room) { + auto settings = QJsonDocument::fromJson(room->getSettings()); + auto password = settings["password"].toString(); + if (password.isEmpty() || arr[1].toString() == password) { + if (room->isOutdated()) { + sender->doNotify("ErrorMsg", "room is outdated"); + } else { + room->addPlayer(sender); + } + } else { + sender->doNotify("ErrorMsg", "room password error"); + } + } else { + sender->doNotify("ErrorMsg", "no such room"); + } +} + +static void observeRoom(ServerPlayer *sender, const QString &jsonData) { + auto arr = String2Json(jsonData).array(); + auto roomId = arr[0].toInt(); + auto room = ServerInstance->findRoom(roomId); + if (room) { + auto settings = QJsonDocument::fromJson(room->getSettings()); + auto password = settings["password"].toString(); + if (password.isEmpty() || arr[1].toString() == password) { + if (room->isOutdated()) { + sender->doNotify("ErrorMsg", "room is outdated"); + } else { + room->addObserver(sender); + } + } else { + sender->doNotify("ErrorMsg", "room password error"); + } + } else { + sender->doNotify("ErrorMsg", "no such room"); + } +} + +static void refreshRoomList(ServerPlayer *sender, const QString &) { + ServerInstance->updateRoomList(sender); +}; + +static void quitRoom(ServerPlayer *player, const QString &) { + auto room = player->getRoom(); + room->removePlayer(player); + if (room->isOutdated()) { + player->kicked(); + } +} + +static void addRobot(ServerPlayer *player, const QString &) { + auto room = player->getRoom(); + if (ServerInstance->getConfig("enableBots").toBool()) + room->addRobot(player); +} + +static void kickPlayer(ServerPlayer *player, const QString &jsonData) { + auto room = player->getRoom(); + int i = jsonData.toInt(); + auto p = room->findPlayer(i); + if (p && !room->isStarted()) { + room->removePlayer(p); + room->addRejectId(i); + QTimer::singleShot(30000, room, [=]() { + room->removeRejectId(i); + }); + } +} + +static void ready(ServerPlayer *player, const QString &) { + auto room = player->getRoom(); + player->setReady(!player->isReady()); + room->doBroadcastNotify(room->getPlayers(), "ReadyChanged", + QString("[%1,%2]").arg(player->getId()).arg(player->isReady())); +} + +static void startGame(ServerPlayer *player, const QString &) { + auto room = player->getRoom(); + if (room->isOutdated()) { + foreach (auto p, room->getPlayers()) { + p->doNotify("ErrorMsg", "room is outdated"); + p->kicked(); + } + } else { + room->manuallyStart(); + } +} + +typedef void (*room_cb)(ServerPlayer *, const QString &); +static const QMap lobby_actions = { + {"UpdateAvatar", updateAvatar}, + {"UpdatePassword", updatePassword}, + {"CreateRoom", createRoom}, + {"EnterRoom", enterRoom}, + {"ObserveRoom", observeRoom}, + {"RefreshRoomList", refreshRoomList}, +}; + +static const QMap room_actions = { + {"QuitRoom", quitRoom}, + {"AddRobot", addRobot}, + {"KickPlayer", kickPlayer}, + {"Ready", ready}, + {"StartGame", startGame}, +}; + +void Room::handlePacket(ServerPlayer *sender, const QString &command, + const QString &jsonData) { + if (command == "Chat") { + chat(sender, jsonData); + return; + } else if (command == "PushRequest") { + if (!isLobby()) + pushRequest(QString("%1,").arg(sender->getId()) + jsonData); + } + + auto func_table = lobby_actions; + if (!isLobby()) func_table = room_actions; + auto func = func_table[command]; + if (func) { + func(sender, jsonData); + } +} diff --git a/src/server/room.h b/src/server/room.h index 2cf9a13a8..221be5b89 100644 --- a/src/server/room.h +++ b/src/server/room.h @@ -50,6 +50,8 @@ class Room : public QObject { int getTimeout() const; void setTimeout(int timeout); + bool isOutdated(); + bool isStarted() const; // ====================================} @@ -65,6 +67,10 @@ class Room : public QObject { void addRejectId(int id); void removeRejectId(int id); + + // router用 + void handlePacket(ServerPlayer *sender, const QString &command, + const QString &jsonData); signals: void abandoned(); @@ -90,6 +96,7 @@ class Room : public QObject { bool m_ready; int timeout; + QString md5; void addRunRate(int id, const QString &mode); void updatePlayerGameData(int id, const QString &mode); diff --git a/src/server/roomthread.cpp b/src/server/roomthread.cpp index 104d592c1..91206caf0 100644 --- a/src/server/roomthread.cpp +++ b/src/server/roomthread.cpp @@ -3,6 +3,7 @@ #include "roomthread.h" #include "server.h" #include "util.h" +#include #ifndef FK_SERVER_ONLY #include "client.h" @@ -13,6 +14,7 @@ RoomThread::RoomThread(Server *m_server) { this->m_server = m_server; m_capacity = 100; // TODO: server cfg terminated = false; + md5 = m_server->getMd5(); L = CreateLuaState(); DoLuaScript(L, "lua/freekill.lua"); @@ -26,6 +28,7 @@ RoomThread::~RoomThread() { wait(); } lua_close(L); + m_server->removeThread(this); // foreach (auto room, room_list) { // room->deleteLater(); // } @@ -40,6 +43,8 @@ bool RoomThread::isFull() const { return m_capacity <= 0; } +QString RoomThread::getMd5() const { return md5; } + Room *RoomThread::getRoom(int id) const { return m_server->findRoom(id); } @@ -52,6 +57,10 @@ void RoomThread::addRoom(Room *room) { void RoomThread::removeRoom(Room *room) { room->setThread(nullptr); m_capacity++; + if (m_capacity == 100 // TODO: server cfg + && isOutdated()) { + deleteLater(); + } } QString RoomThread::fetchRequest() { @@ -118,3 +127,13 @@ bool RoomThread::isConsoleStart() const { return false; #endif } + +bool RoomThread::isOutdated() { + bool ret = md5 != m_server->getMd5(); + if (ret) { + // 让以后每次都outdate + // 不然反复disable/enable的情况下会出乱子 + md5 = ""; + } + return ret; +} diff --git a/src/server/roomthread.h b/src/server/roomthread.h index 15cd19642..25d0afe38 100644 --- a/src/server/roomthread.h +++ b/src/server/roomthread.h @@ -16,6 +16,7 @@ class RoomThread : public QThread { Server *getServer() const; bool isFull() const; + QString getMd5() const; Room *getRoom(int id) const; void addRoom(Room *room); void removeRoom(Room *room); @@ -32,6 +33,9 @@ class RoomThread : public QThread { bool isTerminated() const; bool isConsoleStart() const; + + bool isOutdated(); + protected: virtual void run(); @@ -39,6 +43,7 @@ class RoomThread : public QThread { Server *m_server; // QList room_list; int m_capacity; + QString md5; lua_State *L; QMutex request_queue_mutex; diff --git a/src/server/server.cpp b/src/server/server.cpp index 9a62d90ed..996157b1e 100644 --- a/src/server/server.cpp +++ b/src/server/server.cpp @@ -112,14 +112,14 @@ void Server::createRoom(ServerPlayer *owner, const QString &name, int capacity, RoomThread *thread = nullptr; foreach (auto t, threads) { - if (!t->isFull()) { + if (!t->isFull() && !t->isOutdated()) { thread = t; break; } } + if (!thread && nextRoomId != 0) { - thread = new RoomThread(this); - threads.append(thread); + thread = createThread(); } if (!idle_rooms.isEmpty()) { @@ -128,6 +128,7 @@ void Server::createRoom(ServerPlayer *owner, const QString &name, int capacity, nextRoomId++; room->setAbandoned(false); room->setThread(thread); + thread->addRoom(room); rooms.insert(room->getId(), room); } else { room = new Room(thread); @@ -151,6 +152,16 @@ Room *Server::findRoom(int id) const { return rooms.value(id); } Room *Server::lobby() const { return m_lobby; } +RoomThread *Server::createThread() { + RoomThread *thread = new RoomThread(this); + threads.append(thread); + return thread; +} + +void Server::removeThread(RoomThread *thread) { + threads.removeOne(thread); +} + ServerPlayer *Server::findPlayer(int id) const { return players.value(id); } void Server::addPlayer(ServerPlayer *player) { @@ -183,6 +194,7 @@ void Server::updateRoomList(ServerPlayer *teller) { obj << count; obj << cap; obj << !password.isEmpty(); + obj << room->isOutdated(); if (count == cap) arr << obj; @@ -670,6 +682,30 @@ void Server::endTransaction() { transaction_mutex.unlock(); } +const QString &Server::getMd5() const { + return md5; +} + +void Server::refreshMd5() { + md5 = calcFileMD5(); + foreach (auto room, rooms) { + if (room->isOutdated()) { + if (!room->isStarted()) { + foreach (auto p, room->getPlayers()) { + p->doNotify("ErrorMsg", "room is outdated"); + p->kicked(); + } + } else { + room->doBroadcastNotify(room->getPlayers(), "GameLog", + "{\"type\":\"#RoomOutdated\",\"toast\":true}"); + } + } + } + foreach (auto p, lobby()->getPlayers()) { + emit p->kicked(); + } +} + void Server::readPendingDatagrams() { while (udpSocket->hasPendingDatagrams()) { QNetworkDatagram datagram = udpSocket->receiveDatagram(); diff --git a/src/server/server.h b/src/server/server.h index 61b020889..f2baaa53d 100644 --- a/src/server/server.h +++ b/src/server/server.h @@ -31,6 +31,9 @@ class Server : public QObject { Room *findRoom(int id) const; Room *lobby() const; + RoomThread *createThread(); + void removeThread(RoomThread *thread); + ServerPlayer *findPlayer(int id) const; void addPlayer(ServerPlayer *player); void removePlayer(int id); @@ -50,6 +53,9 @@ class Server : public QObject { void beginTransaction(); void endTransaction(); + const QString &getMd5() const; + void refreshMd5(); + signals: void roomCreated(Room *room); void playerAdded(ServerPlayer *player); diff --git a/src/server/shell.cpp b/src/server/shell.cpp index 9b2ee0cab..c286701de 100644 --- a/src/server/shell.cpp +++ b/src/server/shell.cpp @@ -117,11 +117,13 @@ void Shell::upgradeCommand(QStringList &list) { auto obj = a.toObject(); Pacman->upgradePack(obj["name"].toString()); } + ServerInstance->refreshMd5(); return; } auto pack = list[0]; Pacman->upgradePack(pack); + ServerInstance->refreshMd5(); } void Shell::enableCommand(QStringList &list) { @@ -132,6 +134,7 @@ void Shell::enableCommand(QStringList &list) { auto pack = list[0]; Pacman->enablePack(pack); + ServerInstance->refreshMd5(); } void Shell::disableCommand(QStringList &list) { @@ -142,6 +145,7 @@ void Shell::disableCommand(QStringList &list) { auto pack = list[0]; Pacman->disablePack(pack); + ServerInstance->refreshMd5(); } void Shell::lspkgCommand(QStringList &) { @@ -380,33 +384,32 @@ Shell::Shell() { setObjectName("Shell"); signal(SIGINT, sigintHandler); - static QHash handlers; - if (handlers.size() == 0) { - handlers["help"] = &Shell::helpCommand; - handlers["?"] = &Shell::helpCommand; - handlers["lsplayer"] = &Shell::lspCommand; - handlers["lsroom"] = &Shell::lsrCommand; - handlers["install"] = &Shell::installCommand; - handlers["remove"] = &Shell::removeCommand; - handlers["upgrade"] = &Shell::upgradeCommand; - handlers["u"] = &Shell::upgradeCommand; - handlers["lspkg"] = &Shell::lspkgCommand; - handlers["enable"] = &Shell::enableCommand; - handlers["disable"] = &Shell::disableCommand; - handlers["kick"] = &Shell::kickCommand; - handlers["msg"] = &Shell::msgCommand; - handlers["m"] = &Shell::msgCommand; - handlers["ban"] = &Shell::banCommand; - handlers["unban"] = &Shell::unbanCommand; - handlers["banip"] = &Shell::banipCommand; - handlers["unbanip"] = &Shell::unbanipCommand; - handlers["banuuid"] = &Shell::banUuidCommand; - handlers["unbanuuid"] = &Shell::unbanUuidCommand; - handlers["reloadconf"] = &Shell::reloadConfCommand; - handlers["r"] = &Shell::reloadConfCommand; - handlers["resetpassword"] = &Shell::resetPasswordCommand; - handlers["rp"] = &Shell::resetPasswordCommand; - } + static const QHash handlers = { + {"help", &Shell::helpCommand}, + {"?", &Shell::helpCommand}, + {"lsplayer", &Shell::lspCommand}, + {"lsroom", &Shell::lsrCommand}, + {"install", &Shell::installCommand}, + {"remove", &Shell::removeCommand}, + {"upgrade", &Shell::upgradeCommand}, + {"u", &Shell::upgradeCommand}, + {"lspkg", &Shell::lspkgCommand}, + {"enable", &Shell::enableCommand}, + {"disable", &Shell::disableCommand}, + {"kick", &Shell::kickCommand}, + {"msg", &Shell::msgCommand}, + {"m", &Shell::msgCommand}, + {"ban", &Shell::banCommand}, + {"unban", &Shell::unbanCommand}, + {"banip", &Shell::banipCommand}, + {"unbanip", &Shell::unbanipCommand}, + {"banuuid", &Shell::banUuidCommand}, + {"unbanuuid", &Shell::unbanUuidCommand}, + {"reloadconf", &Shell::reloadConfCommand}, + {"r", &Shell::reloadConfCommand}, + {"resetpassword", &Shell::resetPasswordCommand}, + {"rp", &Shell::resetPasswordCommand}, + }; handler_map = handlers; } diff --git a/src/swig/server.i b/src/swig/server.i index 82908a2b5..ba72a769e 100644 --- a/src/swig/server.i +++ b/src/swig/server.i @@ -41,6 +41,7 @@ public: bool isTerminated() const; bool isConsoleStart() const; + bool isOutdated(); }; %{ From 30e33f92c79645ed768854dcf6fb64a96235b27c Mon Sep 17 00:00:00 2001 From: Nyutanislavsky Date: Sun, 7 Apr 2024 00:45:55 +0800 Subject: [PATCH 3/4] Enhance (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 国战选将框动态显示珠联璧合(等待更清晰的图…) 2. 废除和恢复区域log 3. 搬运isMale,isFemale,compareDistance,hasShownSkill 4. hasSkill如果是状态技,判断是否亮出,未亮出的返回false 5. 标准包调整,修复八卦阵,离间结姻用isMale 6. 亮将技能改用&后缀并详细化prompt 7. 拼点移动起点改为owner 8. 使用牌filter改为owner --------- Co-authored-by: notify --- Fk/Pages/RoomLogic.js | 2 +- Fk/RoomElement/ChooseGeneralBox.qml | 39 ++++++++++++++++-- Fk/RoomElement/GeneralCardItem.qml | 15 +++++++ image/card/general/qun-companions.png | Bin 0 -> 8447 bytes image/card/general/shu-companions.png | Bin 0 -> 8835 bytes image/card/general/wei-companions.png | Bin 0 -> 8974 bytes image/card/general/wild-companions.png | Bin 0 -> 8903 bytes image/card/general/wu-companions.png | Bin 0 -> 8982 bytes lua/client/client_util.lua | 5 +++ lua/client/i18n/zh_CN.lua | 5 ++- lua/core/general.lua | 12 +++++- lua/core/player.lua | 55 ++++++++++++++++++++++++- lua/core/skill_type/active.lua | 2 +- lua/server/events/pindian.lua | 4 +- lua/server/room.lua | 17 +++++++- lua/server/serverplayer.lua | 4 +- packages/standard/aux_skills.lua | 23 +++++------ packages/standard/i18n/en_US.lua | 8 ++-- packages/standard/i18n/zh_CN.lua | 8 ++-- packages/standard/init.lua | 26 ++++++------ packages/standard_cards/init.lua | 3 +- 21 files changed, 182 insertions(+), 46 deletions(-) create mode 100644 image/card/general/qun-companions.png create mode 100644 image/card/general/shu-companions.png create mode 100644 image/card/general/wei-companions.png create mode 100644 image/card/general/wild-companions.png create mode 100644 image/card/general/wu-companions.png diff --git a/Fk/Pages/RoomLogic.js b/Fk/Pages/RoomLogic.js index 39be0dbe3..25dedbe43 100644 --- a/Fk/Pages/RoomLogic.js +++ b/Fk/Pages/RoomLogic.js @@ -954,7 +954,7 @@ callbacks["AskForGeneral"] = (jsonData) => { }); box.choiceNum = n; box.convertDisabled = !!convert; - box.needSameKingdom = !!heg; + box.hegemony = !!heg; for (let i = 0; i < generals.length; i++) box.generalList.append({ "name": generals[i] }); box.updatePosition(); diff --git a/Fk/RoomElement/ChooseGeneralBox.qml b/Fk/RoomElement/ChooseGeneralBox.qml index 50c6adf83..1278a28c4 100644 --- a/Fk/RoomElement/ChooseGeneralBox.qml +++ b/Fk/RoomElement/ChooseGeneralBox.qml @@ -12,7 +12,7 @@ GraphicsBox { property var selectedItem: [] property bool loaded: false property bool convertDisabled: false - property bool needSameKingdom: false + property bool hegemony: false ListModel { id: generalList @@ -230,6 +230,14 @@ GraphicsBox { return false; } + function updateCompanion(gcard1, gcard2, overwrite) { + if (lcall("IsCompanionWith", gcard1.name, gcard2.name)) { + gcard1.hasCompanions = true; + } else if (overwrite) { + gcard1.hasCompanions = false; + } + } + function updatePosition() { choices = []; @@ -248,12 +256,37 @@ GraphicsBox { root.choicesChanged(); fightButton.enabled = (choices.length == choiceNum) && - (needSameKingdom ? isHegPair(selectedItem[0], selectedItem[1]) : true); + (hegemony ? isHegPair(selectedItem[0], selectedItem[1]) : true); for (i = 0; i < generalCardList.count; i++) { item = generalCardList.itemAt(i); - item.selectable = needSameKingdom ? isHegPair(selectedItem[0], item) + item.selectable = hegemony ? isHegPair(selectedItem[0], item) : true; + if (hegemony) { + if (selectedItem[0]) { + if (selectedItem[1]) { + if (selectedItem[0] === item) { + updateCompanion(item, selectedItem[1], true); + } else if (selectedItem[1] === item) { + updateCompanion(item, selectedItem[0], true); + } else { + item.hasCompanions = false; + } + } else { + if (selectedItem[0] !== item) { + updateCompanion(item, selectedItem[0], true); + } else { + for (let j = 0; j < generalList.count; j++) { + updateCompanion(item, generalList.get(j), false); + } + } + } + } else { + for (let j = 0; j < generalList.count; j++) { + updateCompanion(item, generalList.get(j), false); + } + } + } if (selectedItem.indexOf(item) != -1) continue; diff --git a/Fk/RoomElement/GeneralCardItem.qml b/Fk/RoomElement/GeneralCardItem.qml index 87332f2a4..979d4a8e3 100644 --- a/Fk/RoomElement/GeneralCardItem.qml +++ b/Fk/RoomElement/GeneralCardItem.qml @@ -24,6 +24,7 @@ CardItem { property int shieldNum property string pkgName: "" property bool detailed: true + property alias hasCompanions: companions.visible name: "" // description: Sanguosha.getGeneralDescription(name) suit: "" @@ -157,6 +158,7 @@ CardItem { height: 80 x: 2 y: lineCount > 6 ? 30 : 34 + z: 999 text: name !== "" ? luatr(name) : "nil" visible: luatr(name).length <= 6 && detailed && known color: "white" @@ -170,6 +172,7 @@ CardItem { Text { x: 0 y: 12 + z: 999 rotation: 90 transformOrigin: Item.BottomLeft text: luatr(name) @@ -184,6 +187,7 @@ CardItem { visible: pkgName !== "" && detailed && known height: 16 width: childrenRect.width + 4 + z: 100 anchors.bottom: parent.bottom anchors.bottomMargin: 4 anchors.right: parent.right @@ -205,6 +209,17 @@ CardItem { } } + Image { + id: companions + width: parent.width + fillMode: Image.PreserveAspectFit + visible: false + source: SkinBank.getGeneralCardDir(kingdom) + kingdom + "-companions" + anchors.horizontalCenter: parent.horizontalCenter + y: 80 + z: 1 + } + onNameChanged: { const data = lcall("GetGeneralData", name); kingdom = data.kingdom; diff --git a/image/card/general/qun-companions.png b/image/card/general/qun-companions.png new file mode 100644 index 0000000000000000000000000000000000000000..efb117cd7ff994f7a46f8454c5a83b4973bd25b7 GIT binary patch literal 8447 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 D9m6`g literal 0 HcmV?d00001 diff --git a/image/card/general/shu-companions.png b/image/card/general/shu-companions.png new file mode 100644 index 0000000000000000000000000000000000000000..2f06b40c6912251323a382511caec594ff64e3a8 GIT binary patch literal 8835 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 FV1f`TUX=g< literal 0 HcmV?d00001 diff --git a/image/card/general/wei-companions.png b/image/card/general/wei-companions.png new file mode 100644 index 0000000000000000000000000000000000000000..28b31b6be3e9a5f0bb55f251718911f452c78716 GIT binary patch literal 8974 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 literal 0 HcmV?d00001 diff --git a/image/card/general/wild-companions.png b/image/card/general/wild-companions.png new file mode 100644 index 0000000000000000000000000000000000000000..3c1c1d14ced386492ab27f18363cb0cb6effffcb GIT binary patch literal 8903 zcmV;&A~@ZNP)#EL4o-cvw$7ERXhpQ5C<=(kqznRqgb*@9Mlw%#zV{68A4w1? zwohw)-p{-CC!d|W_c?o=^;>(bz0Y3j2#6y89GRchqI6!{IV1oW1OIBD@3M#jCMLQ8 zrU?=f4rHPTgb9ftN+jQ|lCw<3;!~NPU?D-0(NZKjJ0VTcA*k_eG>)S`%E>8FAPVSi zJcf>rF<5kT3_(W{z!V1f83z3G@-sNV1?c=SMd2Jcz6?W5{vP0hX<(WlTu2wfK)V5m zCJ4yHiCN$lg=JSc`px77TXB}pNVX=b!|iT#*%^w3Z1!ZY1!4qF2jW7Uup`$Npvvec zSL^0bZx_?)Wkzlgh}p2m4GaWy1R0{b4uE2jiN>`59|1q7J`4QN1LWqPY-CGwtNp;fgJ^M$BwrGes1gc=FievKkB8pw9_)5IreP9}gh3LSK4TiWdHLas zMiy31A2;nt{B%!^^RVp<)vx!nWldZ}GDN||fG-EQVIU0n%YP8?f0Bd?+-Y8@5#g*` z5drfYmIk`W-9NO2M_%Q#jT@eSu%U9wrY-MGZ9CJU9z9%w7S(au?97?FfaK&P4jw$n zv17;Z`jXJ12IvOk3JbYt;&>Vxn>cdhFwLz^q@@qTo8)2gv?IULW(98HO(Az@iw{HJf7EC z-@<*@g=Hk&_{tzZ5A<^Ym%qd$9~js)5fCs<6HH@J0iQ7w1c||A@vI5P(M0|*$41JM z3_)%$dVS~i#qYhhHL0qq8d(sTK6M5KIeAQ(I)x3dtmoMA;}jJYF@1V5U2W}zwHS%s zMEd*t5HWb^Piv{Jt|mXffbV?gI~an-{sa4|tFL48rfTZz>odcBfu)OQEwWU0y2Vl4 z+oat~NWSlv|8m5Kl=wM!bI#%_eHp;T!7?<)KrMW(NQ3>KfgKY`4cJa3<_)7LWy1Qc z8<)Jk;fFhbNx8HnA+P7~%H{NhPi?6tXU@*X*y}Pm7ti)qc ztg^z)Su?nJ(qtS?H~aVR$K`Udc*)|}sF9=<+B%|VaFkW`hJ79}njh#0JUX)Wa^D;Ya>EEi9iKy_JVsPLk~$ihqKyQddVClrbB z>~l}^+RN(`Eu!+Zt0pgu@XXawHoo5`Y!RaW7^MF*`YT*T&1OyaWK{my+V}=RKdnid5{a*`4Z8`eUqAgpuW?LmIH{WzKO{W?N z`u!xhJk(ZITN)cr*=uU5kR_RxmKG)!72!?tQB_?@EFQ(wG%#T5)F~LcL2Y$4sp-RL zYHY&m^I4CVmCF+^o+Or*mVqfSy||boM@ndJZgi>%9?$sEW7_CV>%ZsVr?FqtXbfQ> zAz;GU`tN~y{)|2sA61kGw?e|eI){M7peAGl`wGrUkSjL&REK;UQB zjjSS-fVe|VasC8bQAJek^>Q(}2(8B?cBUGa zGil5u-rlf@u_H(G&0B7zAUB_;`g&TLnrUrm;iHc}Len%5A#GSH7ORA45s_t?pg+Kz z*>eC;RfXNVchlP1N^^5Fx?!+l#r1sio8KfSJD0z__9jW*6!LQl*uCRpp|s}MB)`&^ z7g{{b9VKEJl4}T}|LJP_MQh-YOA!Yr@=%fYu6R6i{AJUvo#8$?7L9%GrdTX?p|Q@A zlXP780PxcVF4yP!lYB{_n4m-Z+5tTrQHl3AD7dV4^dlcsjfG?7(7?>Fe)979r>BRU z>};aZC=%dQ?Y#Ng2GWNOlQMkRtl|kpdi168 zhN!(VN)$S~I!Ve&0zb5zY5Ch|`~4Sej=2Vu;PVY&qvZHdRaxUqBqFPAAs(86kSMN`(n9# zt7=)c^hy%!Zk8{*7Cjb4K;qcpBlPt25D4^QSDpN2{RXnKGdSJSL}qpd1_oWh5MGB3 zr^AL3)zNjG7hik{(}bdN6F6E@g4>lqb#*nW>fm%;1G8se%3W*jLNf*Qm_gCFiEQ7y zjkp;Z6?WsXq-O_D+t$j!~;%Bz?0gP;C@7JnnD87V}gQ6j;( z&A^a*VtuapqZSs;TrypJ>DgBr0%mumB#9R^bpa7mi;JLSUOf8Zf*yZg@MO!06BbGA z2MEO@NTP^Eu^>q@q97ops-7FQSR7fBaM~P*4EP;YlnF#42{$aiX4J2L|5Leif0d`^ zbY-h(5;qM4(GU>~0bPqrs%*<0lQX(tLeBX7>uY5c@dNyiSKP~+j4T{h(0c{AsX zN>58mJz04&Bx8og7LFk=JCCyBB?~fVbU_PSh3;;Qd848xbIUCz-4z}im>Ri%c(d~PIXl! zJ$?N&w=^h{v^o zU7Kh~sFDP6L+y(6x(wa44R>cb;!zE+YoJj-=(9;uXh6Rvx)jA93nkxn z!>yxk``(R$CvP|iP=BxtkH?cC%a)AJt}bp^xs10qY_@)~Z=X9B>g${|ZnS>%@IIoU zey+ai8tgV3t*x!(7ZmV~+iphJb=I$ch40;cH$HD74?ge+ryJ@3m^^tZE~k@FC`3bJ zBZrS1WADCCDI7h9)vH!<`|8!a{PN3Od+oKj-EO=dH#VDv+its=Q>Pl)x_t|k73EBw zP(;n^Wq70n-@-);N3Z_gtw^eh)9azJsRd#tSKqvxy5kKQPrUSGt4mOWND}BUR2!I5 zqCGkL+MB*EIMP$e8t($4s)6EWO2W?x#Y95`5#Da8DZ18@&`7R@l^U?6D3;=0t`)J9@fD(}C& zleBanGv`jjo0-AE?YnsB{>R#4q4q|*WYN8DH^+{ak(!pw{Dt!f27?qvN5@`&^EKtB zn{MKGc_|APE+on4bkel$4a1smUo~ette%w{GQ~&71h`BfsU9 zKmQq7meF;cJJzfrFF%JDUU-4>6DKfqowT$xe*EJf6N|@r|GoE6WrepkZse+E%eedd z-{+1y?<6xTi`u$cp}D1*csx#CULK{TrA)nK3J4gcAlecV6EJj*gj64O)pdZt2%iVj zFqkxRf;40Aw6xuOw>4Tt0z>}0PuG$!o4q9Os@tz+|2w<5=X<{}XU{7Ze(>|#rQE#C zlt=G>+TL)c>Vy|d2Y@LJy}JOSY%x0fJKHzB`Zl4~E`t62v~+giZ||nFqYJ%1gge2- zkAAw!I(1_4=(uU9LyRLv+?k)7r;fRJ0@-;v^fb4VmY2*8-@1-*#S{5t>t1&4_=ME- zG+z)Q#WX?Jb-KE{DJUqQqN0Mdv@~H<;b?jO{P|Q=RFIdKhbO_qiSlxS!62T51PTia zdHCUn*;i76CQY0C7A(IXN1dixkLGM^D+3m|SFC%Oo{Cayxs)r>l#VT9){KB;NT}E-1A?LgzDOBn*_`OeNs@?0qjYq1 zP*G7qZ*MPezx_7N&CTTIzdpmZ!oywDy9N51f(=@56sUZ*u;BYt)MUm2qa%*AExN#3Wdapfs z#BeNjI~GymR81WxkDdUFL}O(GFFv=Pw(hn*S+ZzI5)r|4^qGu(=z*X5az+m4_>O%n zyZ#CudF~g&iNZADQz z)7^#3=|tBxYHMn+S*^rkF-*fG!RbQNG?J2%2nGW5_4N@52GMmLe;|M-Apt>vU@*w3 zQ>QQt1Ha!-G#aI^uaAzNUTaovhAr$5@yOi|&~dsEhc^M!G*J}`m?oRn{)H#rf3`o# z=4b;58*$J05z{8De`#&z{O>O3=|^8Qes%9}joQju>T2sb{^?QdR+SI8?B-Xu-9yRV zqvRE2Q&^O5Q)IgnKsGSYz?PkpuDIMT0)2k=?%GG;=mJ9he%kgN!D5k#g`&K7XtVvr zUp|}v*4ytTwDdJLSp+3AHGfj_vKyCx2{k3had}+4yKyV?7R*MKL_`znPd89@vWyeu zC);E3K#$YyG7$u7YibCELM>oHJ+6;BYwbdcA}r5$tw5KBtq8t}g6$2Sr6iJoCq= zk!6{lKmfbNf-K8)cXu;<_;8w=oAD(k(%9Zk{)l|`?Ae3Y>t)Q?vGjEHvSP&wDk>_7 z#bVfOHVQ|Lj6C|sXX-!t^kZvBPq&bfotkpRHtbO25)K#C>FS~lNWOq8KI8nt#6UK4r*T2Syk$L>=`wvDoY}^=j zc^rB?7LSC5aE~Zi5Difj-BN<@*4wW4to-q}=xS+W)~u=IW@eh#FZrf^{Ad}Cm5n@d z?;r4GrSkn}e$ET;JfF1sTdOC;Lc*A@fBiB?;mnEr=JtDyCm(%I?`&=-CvzC13-Z|i z(SD}Soy23CpWw$oy90};n24g8;7vr+G~8}CilWfd)5GDzhw1L_X7uRM)YsQDVniOQ zs^WAw`0&H+0Hmd*4a74Y4irTJAUiu7!!+5sYZq64z3+e9 zk?qUQju4eTd$!ZmcOD0-Zs0 zceE)S?XKwTXsL+ycUQ=w7&eF_c~$$3VKXMjI`zcT?MEzcyz!3K*U~4}*47Gn`ME4! za;XsN3-ib4*XcDMmy5kkJ$RE{)SRrPil;k0vd8P^!)~VgM@aN|Ngb7k!xWVd_a4Av5jtZ=B-q>E+an4>I3Cmz7cHDCCuR&| z=CnynSvrqELmT(r{hMf4e`~E*QhRL{OAL!>YJz4)P2H4yj)avqyFFUndOEPXY@ffn zsj6e^{#~JPchvI3AK$KKPcK4i>*vu|o(tBrR90@^wL7}w-5udiN)9y2s!-q5bH-m) zf3j1NtwA9y*iIj+v%dQBoBAJKe!i`~>vU5`Ut8zCkB^ui?>G=|>~5&F7-C@5sL^Ip zViKS1*~{>(Y@ALfeSLkDm6b7P&Kx>BJ2-OW2rE~vq_wq`Q>RXGva%9ck+}Kho0&In z9y4alVA`~4Y}>Yt9Xobl7zT4^&*dApe1k_Gewaye=~R2+V^91l?YnDkwG11UB6hWR;&1EZ#Gz6esu~bXgJB~_ke=)j_H5lJ zcLmy7WI>Eeq7YYQF;w5&ZkM_>PjYq&Efr0?_rm*Zc>Z+`Hyo=~WvSED4b2n;kOWhd zL}Z(aFd{i~v!NM+5sP89+C^EGB|#8`aNO{XOwG@oJZ6F?+8yQ1PdC@Q74dk;jM{X= zkX2EMT4bv(A{Yi5aa~hG@whV-kGKUvh&vRy&nhTU6Hu%+H5QADHqjC^qj7y!@l0dZ zB{O*VR}YYqoW>n@+=0bnVaJXg?BBni)oX6&kw+e3)~s1vfBg;Y{dg~%Hf^H4vlX}7 zOmPFf6jcKdg>{vs;ao}zWeyo3olSyT+E~ilWA>j<@x8I zM-T+otXab!U;UG%vArvAZt<+)9;ZjCtgnvr2K;6$9y7ZG-LX(4tb3gamdW`SxfRjM z)}tR*>oI@L*|Q`QA!_0>Oew=5TkUOuo@h)6$1Q>q@mSRkn<+*H$uzvk| z7A#o6C6`=6-RW8+Nn+RT-ISJ1=v7r&K*GpYp9ZQxhK@rO@PR7A-f3Xq$a$c7n+i~6a)cto+= zMcsunE()R{nxYv;GXzlqQAQGVL`g)K1mq_kc=(TrDQOv)hRb7jL(D+b3>A|j*Ot*U5|GGBZ1_4KEne45hIGUm>i z!^ZXTydKj2tUD8eV_>brvmJM0HIKmn~exYp=aVFc{>PTW+DY zww6#ZY<&N&JHpeZUwkS(F{QDyy(21$BBCgwDk{36<2vtr|9lz>G8nRmArHM3X^6;( znn9>%;Mgf_go(yAe@M}r-jW^z4e9<^mRbkJb-7LT68lHOUDY`nlNb>oRB#DHC1e%+h@p`>Tl0$jGS} z>&)D(#sdxMP9@5j!RSqT92x~G4}bbM0asCpIxS}d4nw5)2drS!unh1;VWa|9w?;}z zM7CU#({#SZL_|!?vq9nWBXcGO0wU62de8a&XLB?Dd!SFnLO0tjWR-U;cy@+ZKD{dS z)%C9??%%hM6)RS-cI{g3x#u1pee_W}Iyxw?I8JM88-ggZdGlrzMZxWM(c0RIrfD=b zHDR~e@caD(FF9@xb#-+-`NR{-%n{-8JA3(#?qxrx&7Z(p(qM7X3XHgfdeEa zCQ^_yqGv(TC3_ow`}X1Cn^twGSmWmdDt0$GyoL_)42;3h<52r&xC~|Rio#%r zEC^>)dk0`L(FTitUKwhMe?j@|It>GZFj4Hxj?<|0(-o|nIcxTjNmC|hFK<{+M&>Z4 z7Ehz6x0^q`xRyKaTEo~eV|ek!7g+lBucN9e!N7pUq@|_da5zvb7IecP(dWbIaG>it z@mQSP+&pH_p2eg8{ol--F@rnqyp!KQ{y5QCm_=7yPDXYnZ*JO1ZovqB?!_~XH14ac z8$PkK-;NqTUv@Cj24(%CzjN^z_-&vK;-aG?VV=cwKp6tE#z2N^)ch=Ls-yGwNJqb5 zF3RU;z(9!goIIDv`yXy@wO&5DWXggWnN4RJM*se~$N9<6?xCflnQhy*V394XS-qOW zhY#cL@2CFMDO5$Fx2K2Bt}a5M5P~Qoi6Ze(jIOpWLj55mQ>L+@iJtC0RTl8<#CyP;+tSm?L{$f2TF;_PLSBeZM$IuAyhYV7B{T zVeq&5{>o$k=>l#4V>16vF)%QN!5WkM#dXHIBb)WKykT+rqY+h>(sz8c)7{+E%_{@PXkD@gwPmwY)gO%p*75C+pO1VMj9O>7|!)>utAEQE`%RD2!B45F_;TB5;Kkn<|-UIQ^=Rtny z+}6KKUuuT`BQzw8X`1GJ_uZ%8D_tHLk1uPdSd#EBE4BuP?fMLC9H(A3+5Q&Cv3U;%BdXJR(h8Vm+QY7CvLmM&x2 ziskIx`!So|dzZK#&d;TztD8rE|2QYBD#^~x*1mPqH><9i zvSeG+OGnEySGrqxaZ1?A1ID>N_*^UeD(NfbAc`W!7kmdJF!c4qQCsFejFftveC)v@s(eX?^@Ye##+zP)<|i()6L zMTkZsB&8%1421}Xf?$B%Y6WD%u^>*Xi{T@3%*o>>^-h^Ir7G8+S!&i9b$N%xR<^YE z!{WFV)i5y;F^QfB`2Pld^)~c{j0aMfhjQ*Bb1sqDb-R@xX1HVHQc}$XDWkf#arnum znu6Mf(I_Mj}N=uPF~&!BP}I0P%u2deO~dLYNJi7ck7;J z`uf{g*Wb_FHzMkNztJQ_;%JBnXFn)J7?=QGWQqTJx&I&0x#`aES#p1vZ&*mpSB?ct zN71^a32ogWU&L-CTD?v;is+1644Wxn5k(P6H{w>&8aASOAl4i1S32mH15%f}M(M@j z5Axfhm=#-mXp0KxCTkjB$+qwB%l-e5295^?juAflVnRX$f<#0xBzRKg=20r+Y$_wh zSP;Y!uL~*bLya_knYP76iD^-ur)n{x4yz V=BqeZu44cI002ovPDHLkV1mM6f?5Co literal 0 HcmV?d00001 diff --git a/image/card/general/wu-companions.png b/image/card/general/wu-companions.png new file mode 100644 index 0000000000000000000000000000000000000000..c8a9c730aafb3367c6a5bb6e32c9556ee34c9da1 GIT binary patch literal 8982 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 literal 0 HcmV?d00001 diff --git a/lua/client/client_util.lua b/lua/client/client_util.lua index 27cbc3453..3ec77179d 100644 --- a/lua/client/client_util.lua +++ b/lua/client/client_util.lua @@ -72,6 +72,11 @@ function GetSameGenerals(name) return json.encode(Fk:getSameGenerals(name)) end +function IsCompanionWith(general, general2) + local _general, _general2 = Fk.generals[general], Fk.generals[general2] + return json.encode(_general:isCompanionWith(_general2)) +end + local cardSubtypeStrings = { [Card.SubtypeNone] = "none", [Card.SubtypeDelayedTrick] = "delayed_trick", diff --git a/lua/client/i18n/zh_CN.lua b/lua/client/i18n/zh_CN.lua index c0315d170..4ab2ac0bc 100644 --- a/lua/client/i18n/zh_CN.lua +++ b/lua/client/i18n/zh_CN.lua @@ -431,9 +431,12 @@ Fk:loadTranslationTable{ ["$DiscardOther"] = "%to 弃置了 %from 的 %arg 张牌 %card", ["$PutToDiscard"] = "%arg 张牌 %card 被置入弃牌堆", + ["#AbortArea"] = "%from 的 %arg 被废除", + ["#ResumeArea"] = "%from 的 %arg 被恢复", + ["#ShowCard"] = "%from 展示了牌 %card", ["#Recast"] = "%from 重铸了 %card", - ["#RecastBySkill"] = "%from 发动了 “%arg” 重铸了 %card", + ["#RecastBySkill"] = "%from 因 “%arg” 重铸了 %card", -- phase ["#PhaseSkipped"] = "%from 跳过了 %arg", diff --git a/lua/core/general.lua b/lua/core/general.lua index 24008eee7..8bd5d0468 100644 --- a/lua/core/general.lua +++ b/lua/core/general.lua @@ -110,8 +110,8 @@ function General:getSkillNameList(include_lord) return ret end ---- 为武将增加珠联璧合关系武将(1个或多个),只需写trueName。 ----@param name string[] @ 武将真名(表) +--- 为武将增加珠联璧合关系武将(1个或多个)。 +---@param name string|string[] @ 武将名(表) function General:addCompanions(name) if type(name) == "table" then table.insertTable(self.companions, name) @@ -120,4 +120,12 @@ function General:addCompanions(name) end end +--- 是否与另一武将构成珠联璧合关系。 +---@param other General @ 另一武将 +function General:isCompanionWith(other) + return table.contains(self.companions, other.name) or table.contains(other.companions, self.name) + or (string.find(self.name, "lord") and (other.kingdom == self.kingdom or other.subkingdom == self.kingdom)) + or (string.find(other.name, "lord") and (self.kingdom == other.kingdom or self.subkingdom == other.kingdom)) +end + return General diff --git a/lua/core/player.lua b/lua/core/player.lua index db78d0e24..33d674e0d 100644 --- a/lua/core/player.lua +++ b/lua/core/player.lua @@ -560,6 +560,30 @@ function Player:distanceTo(other, mode, ignore_dead) return math.max(ret, 1) end +--- 比较距离 +---@param other Player @ 终点角色 +---@param num integer @ 比较基准 +---@param operator string @ 运算符,有 ``"<"`` ``">"`` ``"<="`` ``">="`` ``"=="`` ``"~="`` +---@return boolean @ 返回比较结果,不计入距离结果永远为false +function Player:compareDistance(other, num, operator) + local distance = self:distanceTo(other) + if distance < 0 or num < 0 then return false end + if operator == ">" then + return distance > num + elseif operator == "<" then + return distance < num + elseif operator == "==" then + return distance == num + elseif operator == ">=" then + return distance >= num + elseif operator == "<=" then + return distance <= num + elseif operator == "~=" then + return distance ~= num + end + return false +end + --- 获取其他玩家是否在玩家的攻击范围内。 ---@param other Player @ 其他玩家 ---@param fixLimit? integer @ 卡牌距离限制增加专用 @@ -762,7 +786,12 @@ function Player:hasSkill(skill, ignoreNullified, ignoreAlive) end if table.contains(self.player_skills, skill) then - return true + if not skill:isInstanceOf(StatusSkill) then return true end + if self:isInstanceOf(ServerPlayer) then + return not self:isFakeSkill(skill) + else + return table.contains(self.player_skills, skill) + end end if self:isInstanceOf(ServerPlayer) and -- isInstanceOf(nil) will return false @@ -781,6 +810,20 @@ function Player:hasSkill(skill, ignoreNullified, ignoreAlive) return false end +--- 技能是否亮出 +---@param skill string | Skill +---@return boolean +function Player:hasShownSkill(skill, ignoreNullified, ignoreAlive) + if not self:hasSkill(skill, ignoreNullified, ignoreAlive) then return false end + + if self:isInstanceOf(ServerPlayer) then + return not self:isFakeSkill(skill) + else + if type(skill) == "string" then skill = Fk.skills[skill] end + return table.contains(self.player_skills, skill) + end +end + --- 为玩家增加对应技能。 ---@param skill string | Skill @ 技能名 ---@param source_skill? string | Skill @ 本有技能(和衍生技能相对) @@ -1137,4 +1180,14 @@ function Player:compareGenderWith(other, diff) end end +--- 是否为男性(包括双性)。 +function Player:isMale() + return self.gender == General.Male or self.gender == General.Bigender +end + +--- 是否为女性(包括双性)。 +function Player:isFemale() + return self.gender == General.Female or self.gender == General.Bigender +end + return Player diff --git a/lua/core/skill_type/active.lua b/lua/core/skill_type/active.lua index 3f13b76f3..9c5d13c9e 100644 --- a/lua/core/skill_type/active.lua +++ b/lua/core/skill_type/active.lua @@ -209,7 +209,7 @@ function ActiveSkill:onUse(room, cardUseEvent) end ---@param room Room ---@param cardUseEvent CardUseStruct ----@param isEnding? bool +---@param finished? bool function ActiveSkill:onAction(room, cardUseEvent, finished) end ---@param room Room diff --git a/lua/server/events/pindian.lua b/lua/server/events/pindian.lua index c1c593a40..0982a373f 100644 --- a/lua/server/events/pindian.lua +++ b/lua/server/events/pindian.lua @@ -38,7 +38,7 @@ GameEvent.functions[GameEvent.Pindian] = function(self) table.insert(moveInfos, { ids = { _pindianCard.id }, - from = pindianData.from.id, + from = room.owner_map[_pindianCard.id], fromArea = room:getCardArea(_pindianCard.id), toArea = Card.Processing, moveReason = fk.ReasonPut, @@ -56,7 +56,7 @@ GameEvent.functions[GameEvent.Pindian] = function(self) table.insert(moveInfos, { ids = { _pindianCard.id }, - from = to.id, + from = room.owner_map[_pindianCard.id], fromArea = room:getCardArea(_pindianCard.id), toArea = Card.Processing, moveReason = fk.ReasonPut, diff --git a/lua/server/room.lua b/lua/server/room.lua index 600937cb0..789b4bf45 100644 --- a/lua/server/room.lua +++ b/lua/server/room.lua @@ -3040,6 +3040,7 @@ end ---@param tos ServerPlayer | ServerPlayer[] @ 目标角色(列表) ---@param skillName? string @ 技能名 ---@param extra? boolean @ 是否不计入次数 +---@return CardUseStruct function Room:useVirtualCard(card_name, subcards, from, tos, skillName, extra) local card = Fk:cloneCard(card_name) card.skillName = skillName @@ -3064,7 +3065,7 @@ function Room:useVirtualCard(card_name, subcards, from, tos, skillName, extra) use.extraUse = extra self:useCard(use) - return true + return use end ------------------------------------------------------------------------ @@ -3936,6 +3937,13 @@ function Room:abortPlayerArea(player, playerSlots) table.insertTable(player.sealedSlots, slotsToSeal) self:broadcastProperty(player, "sealedSlots") + for _, s in ipairs(slotsToSeal) do + self:sendLog{ + type = "#AbortArea", + from = player.id, + arg = s, + } + end self.logic:trigger(fk.AreaAborted, player, { slots = slotsSealed }) end @@ -3961,6 +3969,13 @@ function Room:resumePlayerArea(player, playerSlots) if #slotsToResume > 0 then self:broadcastProperty(player, "sealedSlots") + for _, s in ipairs(slotsToResume) do + self:sendLog{ + type = "#ResumeArea", + from = player.id, + arg = s, + } + end self.logic:trigger(fk.AreaResumed, player, { slots = slotsToResume }) end end diff --git a/lua/server/serverplayer.lua b/lua/server/serverplayer.lua index e76e88fe6..be723d0d9 100644 --- a/lua/server/serverplayer.lua +++ b/lua/server/serverplayer.lua @@ -943,7 +943,7 @@ function ServerPlayer:revealGeneral(isDeputy, no_trigger) end end if ret then - self:loseFakeSkill("reveal_skill") + self:loseFakeSkill("reveal_skill&") end local oldKingdom = self.kingdom @@ -1058,7 +1058,7 @@ function ServerPlayer:hideGeneral(isDeputy) local s = Fk.skills[sname] if s.relate_to_place ~= place then if s.frequency == Skill.Compulsory then - self:addFakeSkill("reveal_skill") + self:addFakeSkill("reveal_skill&") end self:addFakeSkill(s) end diff --git a/packages/standard/aux_skills.lua b/packages/standard/aux_skills.lua index 408c3521d..42b310903 100644 --- a/packages/standard/aux_skills.lua +++ b/packages/standard/aux_skills.lua @@ -271,8 +271,8 @@ local revealProhibited = fk.CreateInvaliditySkill { -- 亮将 local revealSkill = fk.CreateActiveSkill{ - name = "reveal_skill", - prompt = "#reveal_skill", + name = "reveal_skill&", + prompt = "#reveal_skill&", interaction = function(self) local choiceList = {} if (Self.general == "anjiang" and not Self:prohibitReveal()) then @@ -280,7 +280,7 @@ local revealSkill = fk.CreateActiveSkill{ for _, sname in ipairs(general:getSkillNameList(true)) do local s = Fk.skills[sname] if s.frequency == Skill.Compulsory and s.relate_to_place ~= "m" then - table.insert(choiceList, "revealMain") + table.insert(choiceList, "revealMain:::" .. general.name) break end end @@ -290,13 +290,13 @@ local revealSkill = fk.CreateActiveSkill{ for _, sname in ipairs(general:getSkillNameList(true)) do local s = Fk.skills[sname] if s.frequency == Skill.Compulsory and s.relate_to_place ~= "d" then - table.insert(choiceList, "revealDeputy") + table.insert(choiceList, "revealDeputy:::" .. general.name) break end end end if #choiceList == 0 then return false end - return UI.ComboBox { choices = choiceList} + return UI.ComboBox { choices = choiceList } end, target_num = 0, card_num = 0, @@ -305,18 +305,16 @@ local revealSkill = fk.CreateActiveSkill{ local player = room:getPlayerById(effect.from) local choice = self.interaction.data if not choice then return false - elseif choice == "revealMain" then player:revealGeneral(false) - elseif choice == "revealDeputy" then player:revealGeneral(true) end + elseif choice:startsWith("revealMain") then player:revealGeneral(false) + elseif choice:startsWith("revealDeputy") then player:revealGeneral(true) end end, can_use = function(self, player) - local choiceList = {} if (player.general == "anjiang" and not player:prohibitReveal()) then local general = Fk.generals[player:getMark("__heg_general")] for _, sname in ipairs(general:getSkillNameList(true)) do local s = Fk.skills[sname] if s.frequency == Skill.Compulsory and s.relate_to_place ~= "m" then - table.insert(choiceList, "revealMain") - break + return true end end end @@ -325,12 +323,11 @@ local revealSkill = fk.CreateActiveSkill{ for _, sname in ipairs(general:getSkillNameList(true)) do local s = Fk.skills[sname] if s.frequency == Skill.Compulsory and s.relate_to_place ~= "d" then - table.insert(choiceList, "revealDeputy") - break + return true end end end - return #choiceList > 0 + return false end } diff --git a/packages/standard/i18n/en_US.lua b/packages/standard/i18n/en_US.lua index 173de1fb7..bef7f82d8 100644 --- a/packages/standard/i18n/en_US.lua +++ b/packages/standard/i18n/en_US.lua @@ -195,9 +195,11 @@ Fk:loadTranslationTable({ ["ex__choose_skill"] = "Choose", ["distribution_select_skill"] = "Distribute", ["choose_players_to_move_card_in_board"] = "Choose players", - ["reveal_skill"] = "Reveal", - ["#reveal_skill"] = "Choose a character to reveal", - [":reveal_skill"] = "In action phase, you can reveal a character who has Forced skills.", + ["reveal_skill&"] = "Reveal", + ["#reveal_skill&"] = "Choose a character to reveal", + [":reveal_skill&"] = "In action phase, you can reveal a character who has Forced skills.", + ["revealMain"] = "Reveal main character %arg", + ["revealDeputy"] = "Reveal deputy character %arg", ["game_rule"] = "Discard", }, "en_US") diff --git a/packages/standard/i18n/zh_CN.lua b/packages/standard/i18n/zh_CN.lua index acfd0c31b..b633733ce 100644 --- a/packages/standard/i18n/zh_CN.lua +++ b/packages/standard/i18n/zh_CN.lua @@ -522,9 +522,11 @@ Fk:loadTranslationTable{ ["ex__choose_skill"] = "选择", ["distribution_select_skill"] = "分配", ["choose_players_to_move_card_in_board"] = "选择角色", - ["reveal_skill"] = "亮将", - ["#reveal_skill"] = "选择一个武将亮将(点击左侧选择框展开)", - [":reveal_skill"] = "出牌阶段,你可亮出一张有锁定技的武将。", + ["reveal_skill&"] = "亮将", + ["#reveal_skill&"] = "选择一个武将亮将(点击左侧选择框展开)", + [":reveal_skill&"] = "出牌阶段,你可明置一张有锁定技的武将。", + ["revealMain"] = "明置主将 %arg", + ["revealDeputy"] = "明置副将 %arg", ["game_rule"] = "弃牌阶段", } diff --git a/packages/standard/init.lua b/packages/standard/init.lua index 90d4c728c..d1bc5d2af 100644 --- a/packages/standard/init.lua +++ b/packages/standard/init.lua @@ -170,10 +170,11 @@ local tuxi = fk.CreateTriggerSkill{ end, on_use = function(self, event, target, player, data) local room = player.room + room:sortPlayersByAction(self.cost_data) for _, id in ipairs(self.cost_data) do if player.dead then return end local p = room:getPlayerById(id) - if not p.dead then + if not p.dead and not p:isKongcheng() then local c = room:askForCardChosen(player, p, "h", self.name) room:obtainCard(player.id, c, false, fk.ReasonPrey) end @@ -689,6 +690,9 @@ local qixi = fk.CreateViewAsSkill{ c:addSubcard(cards[1]) return c end, + enabled_at_response = function (self, player, response) + return not response + end } local ganning = General:new(extension, "ganning", "wu", 4) ganning:addSkill(qixi) @@ -759,7 +763,7 @@ local fanjian = fk.CreateActiveSkill{ can_use = function(self, player) return player:usedSkillTimes(self.name, Player.HistoryPhase) == 0 end, - card_filter = function() return false end, + card_filter = Util.FalseFunc, target_filter = function(self, to_select, selected) return #selected == 0 and to_select ~= Self.id end, @@ -801,6 +805,9 @@ local guose = fk.CreateViewAsSkill{ c:addSubcard(cards[1]) return c end, + enabled_at_response = function (self, player, response) + return not response + end } local liuli = fk.CreateTriggerSkill{ name = "liuli", @@ -937,9 +944,7 @@ local jieyin = fk.CreateActiveSkill{ end, target_filter = function(self, to_select, selected) local target = Fk:currentRoom():getPlayerById(to_select) - return target:isWounded() and - (target.gender == General.Male or target.gender == General.Bigender) - and #selected < 1 and to_select ~= Self.id + return target:isWounded() and target:isMale() and #selected < 1 and to_select ~= Self.id end, target_num = 1, card_num = 2, @@ -1015,11 +1020,9 @@ local jijiu = fk.CreateViewAsSkill{ c:addSubcard(cards[1]) return c end, - enabled_at_play = function(self, player) - return false - end, - enabled_at_response = function(self, player) - return player.phase == Player.NotActive + enabled_at_play = Util.FalseFunc, + enabled_at_response = function(self, player, res) + return player.phase == Player.NotActive and not res end, } local huatuo = General:new(extension, "huatuo", "qun", 3) @@ -1067,8 +1070,7 @@ local lijian = fk.CreateActiveSkill{ end, target_filter = function(self, to_select, selected) if #selected < 2 and to_select ~= Self.id then - local target = Fk:currentRoom():getPlayerById(to_select) - return target.gender == General.Male or target.gender == General.Bigender + return Fk:currentRoom():getPlayerById(to_select):isMale() end end, target_num = 2, diff --git a/packages/standard_cards/init.lua b/packages/standard_cards/init.lua index b43b9580e..d4827b6ad 100644 --- a/packages/standard_cards/init.lua +++ b/packages/standard_cards/init.lua @@ -1157,7 +1157,8 @@ local eightDiagramSkill = fk.CreateTriggerSkill{ events = {fk.AskForCardUse, fk.AskForCardResponse}, can_trigger = function(self, event, target, player, data) return target == player and player:hasSkill(self) and - (data.cardName == "jink" or (data.pattern and Exppattern:Parse(data.pattern):matchExp("jink|0|nosuit|none"))) + (data.cardName == "jink" or (data.pattern and Exppattern:Parse(data.pattern):matchExp("jink|0|nosuit|none"))) and + (event == fk.AskForCardUse and not player:prohibitUse(Fk:cloneCard("jink")) or not player:prohibitResponse(Fk:cloneCard("jink"))) end, on_use = function(self, event, target, player, data) local room = player.room From e75836ff8dc1fa70edfe580d3e3ed48a73930b39 Mon Sep 17 00:00:00 2001 From: YoumuKon <38815081+YoumuKon@users.noreply.github.com> Date: Sun, 7 Apr 2024 00:51:29 +0800 Subject: [PATCH 4/4] bugfix (#339) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改造常备主逻辑,现在常备主会根据可用多余将数调整 - 改良了getCardArea - 重新启用将框不足提醒 - 修复了司马懿问打出牌的bug - 修复了白名单武将不存在时无法创房的bug --------- Co-authored-by: notify --- Fk/LobbyElement/RoomGeneralSettings.qml | 4 ++- Fk/Pages/GeneralsOverview.qml | 4 +-- lua/client/client.lua | 33 ++++++++++++++----------- lua/client/client_util.lua | 2 +- lua/server/room.lua | 7 ++---- lua/server/system_enum.lua | 1 + packages/standard/init.lua | 11 +++++---- 7 files changed, 33 insertions(+), 29 deletions(-) diff --git a/Fk/LobbyElement/RoomGeneralSettings.qml b/Fk/LobbyElement/RoomGeneralSettings.qml index 0da41eda6..575b2423b 100644 --- a/Fk/LobbyElement/RoomGeneralSettings.qml +++ b/Fk/LobbyElement/RoomGeneralSettings.qml @@ -172,7 +172,9 @@ Flickable { arr = config.curScheme.banPkg[k]; if (arr.length !== 0) { const generals = lcall("GetGenerals", k); - disabledGenerals.push(...generals.filter(g => !arr.includes(g))); + if (generals.length !== 0) { + disabledGenerals.push(...generals.filter(g => !arr.includes(g))); + } } } for (k in config.curScheme.normalPkg) { diff --git a/Fk/Pages/GeneralsOverview.qml b/Fk/Pages/GeneralsOverview.qml index 51daf5163..abb850e5f 100644 --- a/Fk/Pages/GeneralsOverview.qml +++ b/Fk/Pages/GeneralsOverview.qml @@ -634,8 +634,8 @@ Item { function loadPackages() { if (loaded) return; - const _mods = lcall("GetAllModNames") - const modData = lcall("GetAllMods") + const _mods = lcall("GetAllModNames"); + const modData = lcall("GetAllMods"); const packs = lcall("GetAllGeneralPack"); _mods.forEach(name => { const pkgs = modData[name].filter(p => packs.includes(p) diff --git a/lua/client/client.lua b/lua/client/client.lua index 71ae114ab..31b9ad034 100644 --- a/lua/client/client.lua +++ b/lua/client/client.lua @@ -66,25 +66,28 @@ function Client:getPlayerById(id) return nil end ----@param cardId integer | card +---@param cardId integer | Card ---@return CardArea function Client:getCardArea(cardId) - if type(cardId) ~= "number" then - assert(cardId and cardId:isInstanceOf(Card)) - cardId = cardId:getEffectiveId() - end - if table.contains(Self.player_cards[Player.Hand], cardId) then - return Card.PlayerHand - end - if table.contains(Self.player_cards[Player.Equip], cardId) then - return Card.PlayerEquip - end - for _, t in pairs(Self.special_cards) do - if table.contains(t, cardId) then - return Card.PlayerSpecial + local cardIds = Card:getIdList(cardId) + local resultPos = {} + for _, cid in ipairs(cardIds) do + if not table.contains(resultPos, Card.PlayerHand) and table.contains(Self.player_cards[Player.Hand], cid) then + table.insert(resultPos, Card.PlayerHand) + end + if not table.contains(resultPos, Card.PlayerEquip) and table.contains(Self.player_cards[Player.Equip], cid) then + table.insert(resultPos, Card.PlayerEquip) end + for _, t in pairs(Self.special_cards) do + if table.contains(t, cid) then + table.insertIfNeed(resultPos, Card.PlayerSpecial) + end + end + end + if #resultPos == 1 then + return resultPos[1] end - error("Client:getCardArea can only judge cards in your hand or equip area") + return Card.Unknown end function Client:moveCards(moves) diff --git a/lua/client/client_util.lua b/lua/client/client_util.lua index 3ec77179d..b68bfd1d0 100644 --- a/lua/client/client_util.lua +++ b/lua/client/client_util.lua @@ -151,7 +151,7 @@ function GetAllGeneralPack() end function GetGenerals(pack_name) - if not Fk.packages[pack_name] then return "{}" end + if not Fk.packages[pack_name] then return "[]" end local ret = {} for _, g in ipairs(Fk.packages[pack_name].generals) do if not g.total_hidden then diff --git a/lua/server/room.lua b/lua/server/room.lua index 789b4bf45..5e0933cf8 100644 --- a/lua/server/room.lua +++ b/lua/server/room.lua @@ -265,11 +265,8 @@ end ---@param cardId integer | Card @ 要获得区域的那张牌,可以是Card或者一个id ---@return CardArea @ 这张牌的区域 function Room:getCardArea(cardId) - if type(cardId) ~= "number" then - assert(cardId and cardId:isInstanceOf(Card)) - cardId = cardId:getEffectiveId() - end - return self.card_place[cardId] or Card.Unknown + local cardIds = table.map(Card:getIdList(cardId), function(cid) return self.card_place[cid] or Card.Unknown end) + return #cardIds == 1 and cardIds[1] or Card.Unknown end --- 获得拥有某一张牌的玩家。 diff --git a/lua/server/system_enum.lua b/lua/server/system_enum.lua index 55b244cd5..381c97515 100644 --- a/lua/server/system_enum.lua +++ b/lua/server/system_enum.lua @@ -71,6 +71,7 @@ fk.IceDamage = 4 ---@field public damageType? DamageType @ 伤害的属性 ---@field public skillName? string @ 造成本次伤害的技能名 ---@field public beginnerOfTheDamage? boolean @ 是否是本次铁索传导的起点 +---@field public by_user? boolean @ 是否由卡牌直接生效造成的伤害 --- 用来描述和回复体力有关的数据。 ---@class RecoverStruct diff --git a/packages/standard/init.lua b/packages/standard/init.lua index d1bc5d2af..3f0327a87 100644 --- a/packages/standard/init.lua +++ b/packages/standard/init.lua @@ -84,14 +84,14 @@ local guicai = fk.CreateTriggerSkill{ on_cost = function(self, event, target, player, data) local room = player.room local prompt = "#guicai-ask::" .. target.id - local card = room:askForResponse(player, self.name, ".|.|.|hand", prompt, true) - if card ~= nil then - self.cost_data = card + local card = room:askForCard(player, 1, 1, false, self.name, true, ".|.|.|hand", prompt) + if #card > 0 then + self.cost_data = card[1] return true end end, on_use = function(self, event, target, player, data) - player.room:retrial(self.cost_data, player, data, self.name) + player.room:retrial(Fk:getCardById(self.cost_data), player, data, self.name) end, } local fankui = fk.CreateTriggerSkill{ @@ -1119,7 +1119,7 @@ local role_getlogic = function() if lord ~= nil then room.current = lord local a1 = #room.general_pile - local a2 = #room.players * generalNum + lord_num + local a2 = #room.players * generalNum if a1 < a2 then room:sendLog{ type = "#NoEnoughGeneralDraw", @@ -1129,6 +1129,7 @@ local role_getlogic = function() } room:gameOver("") end + lord_num = math.min(a1 - a2, lord_num) local generals = table.connect(room:findGenerals(function(g) return table.find(Fk.generals[g].skills, function(s) return s.lordSkill end) end, lord_num), room:getNGenerals(generalNum))