From 6abcc91fca995937266d01d7f3982e6ff6b5c9fa Mon Sep 17 00:00:00 2001 From: stribog Date: Sat, 29 Aug 2026 19:48:28 +0200 Subject: [PATCH 1/3] =?UTF-8?q?refactor(who):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B1=D1=83=D1=84=D0=B5=D1=80=D1=8B=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20"=D0=BA?= =?UTF-8?q?=D1=82=D0=BE"=20(#3807)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Команда собирала весь вывод в глобальных buf, buf1 и arg -- общих на весь процесс. Любой вызов посреди сборки строки затирал накопленное, а переполнение ловилось только сторожевым байтом, уже постфактум. Разбор аргументов идёт через utils::ExtractFirstArgument -- строковый аналог one_argument, без фиксированных буферов. Слово приводим к нижнему регистру и чистим остаток сами: ExtractFirstArgument этого не делает, а half_chop делал. Строка игрока, заголовки разделов и счётчики собираются fmt::format в std::string. sprintf, strcat и фиксированных буферов в файле не осталось. Заодно две правки по существу. "кто боги" не работало: условие было if (!str_cmp(arg, "боги") && strlen(arg) == 4) В KOI8-R "боги" занимало 4 байта, в UTF-8 -- 8, поэтому вторая половина условия перестала выполняться и слово уходило в поиск по имени. Проверка длины тут и не нужна: str_cmp сравнивает строки целиком, а не по префиксу. В коротком списке ("кто -s") имя сперва оборачивали в цвет, а потом дополняли до тридцати знаков. Цветовой код -- семь невидимых символов, сброс ещё семь, и fmt считает их наравне с буквами: в колонке оставалось 16 видимых знаков вместо тридцати. Теперь ширина добирается внутри цветов. Снят устаревший комментарий о том, что fmt меряет ширину в байтах: это было верно, пока русский текст лежал в KOI8-R и не был валидным UTF-8. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XUwDWDnYdXrJdvjDVd36QH --- src/engine/ui/cmd/do_who.cpp | 211 +++++++++++++++++++---------------- 1 file changed, 113 insertions(+), 98 deletions(-) diff --git a/src/engine/ui/cmd/do_who.cpp b/src/engine/ui/cmd/do_who.cpp index 198d7b295..9f01d5448 100644 --- a/src/engine/ui/cmd/do_who.cpp +++ b/src/engine/ui/cmd/do_who.cpp @@ -5,6 +5,9 @@ #include "engine/ui/cmd/do_who.h" #include "utils/russian_keys.h" #include "utils/native_text.h" +#include "utils/utils_string.h" +#include +#include #include #include "administration/privilege.h" #include "utils/grammar/gender.h" @@ -25,11 +28,21 @@ const char *IMM_WHO_FORMAT = const char *MORT_WHO_FORMAT = "Формат: кто [имя] [-?]\r\n"; +// Первое слово в нижнем регистре и остаток -- utils::ExtractFirstArgument плюс то, что +// half_chop делал сам: понижение регистра (сравнения ниже рассчитывают на него) и срез +// ведущих пробелов в остатке. +std::pair ChopWord(const std::string &line) { + std::string rest; + std::string word = utils::ExtractFirstArgument(line, rest); + native_text::to_lower(word); + utils::TrimLeft(rest); + return {std::move(word), std::move(rest)}; +} + } // namespace void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { - char name_search[kMaxInputLength]; - name_search[0] = '\0'; + std::string name_search; // Флаги для опций int low = 0, high = kLvlImplementator; @@ -40,58 +53,69 @@ void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { ECharClass showclass{ECharClass::kUndefined}; skip_spaces(&argument); - strcpy(buf, argument); + // Разбор идёт по своим строкам, а не по глобальным buf/arg/buf1: те общие на весь процесс, + // и любой вызов посреди разбора затирал бы разобранное (issue #3807). + std::string rest = argument ? argument : ""; // Проверка аргументов команды "кто" - while (*buf) { - half_chop(buf, arg, buf1); - if (!str_cmp(arg, "боги") && strlen(arg) == 4) { + while (!rest.empty()) { + auto [token, tail] = ChopWord(rest); + if (token.empty()) { + break; + } + if (token == "боги") { low = kLvlImmortal; high = kLvlImplementator; - strcpy(buf, buf1); - } else if (a_isdigit(*arg)) { + rest = tail; + } else if (a_isdigit(token.front())) { if (privilege::IsGod(ch) || ch->IsFlagged(EPrf::kCoderinfo)) - sscanf(arg, "%d-%d", &low, &high); - strcpy(buf, buf1); - } else if (*arg == '-') { - const char32_t mode = native_text::first_char_code(arg + 1); // just in case; we destroy arg in the switch + sscanf(token.c_str(), "%d-%d", &low, &high); + rest = tail; + } else if (token.front() == '-') { + const char32_t mode = native_text::first_char_code(token.c_str() + 1); switch (mode) { case 'b': case rus::kI: if (privilege::IsImmortal(ch) || GET_GOD_FLAG(ch, EGf::kDemigod) || ch->IsFlagged(EPrf::kCoderinfo)) showname = true; - strcpy(buf, buf1); + rest = tail; break; case 'z': if (privilege::IsGod(ch) || ch->IsFlagged(EPrf::kCoderinfo)) localwho = true; - strcpy(buf, buf1); + rest = tail; break; case 's': if (privilege::IsImmortal(ch) || ch->IsFlagged(EPrf::kCoderinfo)) short_list = true; - strcpy(buf, buf1); + rest = tail; break; - case 'l': half_chop(buf1, arg, buf); + case 'l': { + auto [value, next] = ChopWord(tail); + rest = next; if (privilege::IsGod(ch) || ch->IsFlagged(EPrf::kCoderinfo)) - sscanf(arg, "%d-%d", &low, &high); + sscanf(value.c_str(), "%d-%d", &low, &high); break; - case 'n': half_chop(buf1, name_search, buf); + } + case 'n': { + auto [value, next] = ChopWord(tail); + name_search = value; + rest = next; break; + } case 'r': if (privilege::IsGod(ch) || ch->IsFlagged(EPrf::kCoderinfo)) who_room = true; - strcpy(buf, buf1); + rest = tail; break; - case 'c': half_chop(buf1, arg, buf); + case 'c': { + auto [value, next] = ChopWord(tail); + rest = next; if (privilege::IsGod(ch) || ch->IsFlagged(EPrf::kCoderinfo)) { -/* const size_t len = strlen(arg); - for (size_t i = 0; i < len; i++) { - showclass |= FindCharClassMask(arg[i]); - }*/ - showclass = FindAvailableCharClassId(arg); + showclass = FindAvailableCharClassId(value.c_str()); } break; + } case 'h': case '?': default: @@ -103,24 +127,18 @@ void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { } // end of switch } else // endif { - strcpy(name_search, arg); - strcpy(buf, buf1); - + name_search = token; + rest = tail; } } // end while (parser) - if (PerformWhoSpamcontrol(ch, strlen(name_search) ? kWhoListname : kWhoListall)) + if (PerformWhoSpamcontrol(ch, name_search.empty() ? kWhoListall : kWhoListname)) return; // Строки содержащие имена - sprintf(buf, "%sБОГИ%s\r\n", kColorBoldCyn, kColorNrm); - std::string imms(buf); - - sprintf(buf, "%sПривилегированные%s\r\n", kColorCyn, kColorNrm); - std::string demigods(buf); - - sprintf(buf, "%sИгроки%s\r\n", kColorCyn, kColorNrm); - std::string morts(buf); + std::string imms = fmt::format("{}БОГИ{}\r\n", kColorBoldCyn, kColorNrm); + std::string demigods = fmt::format("{}Привилегированные{}\r\n", kColorCyn, kColorNrm); + std::string morts = fmt::format("{}Игроки{}\r\n", kColorCyn, kColorNrm); int all = 0; @@ -137,7 +155,7 @@ void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { ++all; } - if (*name_search && !(isname(name_search, GET_NAME(tch)))) { + if (!name_search.empty() && !isname(name_search, GET_NAME(tch))) { continue; } @@ -162,109 +180,110 @@ void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { continue; } - *buf = '\0'; + // Строка игрока собирается в свою std::string: глобальный buf на это не годится -- + // он общий на весь процесс и переполняется молча (issue #3807). + std::string line; num_can_see++; if (short_list) { - char tmp[kMaxInputLength]; - snprintf(tmp, sizeof(tmp), "%s%s%s", GetPkNameColor(tch), GET_NAME(tch), kColorNrm); - // Ширина колонки - в символах, а не в байтах (issue #3681): fmt "{:<30}" считает - // байты, из-за чего колонка с русским именем под UTF-8 выходит вдвое уже. + // Ширину добираем ВНУТРИ цветовых кодов, а не поверх них: код цвета -- семь невидимых + // символов, и "{:<30}" по строке вместе с ними давал колонку не в 30 знаков, а в 16. + const std::string colored_name = + fmt::format("{}{:<30}{}", GetPkNameColor(tch), GET_NAME(tch), kColorNrm); if (privilege::IsImpl(ch) || ch->IsFlagged(EPrf::kCoderinfo)) { - strcpy(buf, fmt::format("{}[{:2} {}] {:<30}{}", - privilege::IsGod(tch.get()) ? kColorWht : "", - GetRealLevel(tch), MUD::Class(tch->GetClass()).GetCName(), - tmp, - privilege::IsGod(tch.get()) ? kColorNrm : "").c_str()); + line = fmt::format("{}[{:2} {}] {}{}", + privilege::IsGod(tch.get()) ? kColorWht : "", + GetRealLevel(tch), MUD::Class(tch->GetClass()).GetCName(), + colored_name, + privilege::IsGod(tch.get()) ? kColorNrm : ""); } else { - strcpy(buf, fmt::format("{}{:<30}{}", - privilege::IsImmortal(tch.get()) ? kColorWht : "", - tmp, - privilege::IsImmortal(tch.get()) ? kColorNrm : "").c_str()); + line = fmt::format("{}{}{}", + privilege::IsImmortal(tch.get()) ? kColorWht : "", + colored_name, + privilege::IsImmortal(tch.get()) ? kColorNrm : ""); } } else { if (privilege::IsImpl(ch) || ch->IsFlagged(EPrf::kCoderinfo)) { - sprintf(buf, "%s[%2d %2d %s(%5d)] %s%s%s%s", - privilege::IsImmortal(tch.get()) ? kColorWht : "", - GetRealLevel(tch), - remort::GetRealRemort(tch), - MUD::Class(tch->GetClass()).GetAbbr().c_str(), - tch->get_pfilepos(), - GetPkNameColor(tch), - privilege::IsImmortal(tch.get()) ? kColorWht : "", tch->race_or_title().c_str(), kColorNrm); + line = fmt::format("{}[{:2d} {:2d} {}({:5d})] {}{}{}{}", + privilege::IsImmortal(tch.get()) ? kColorWht : "", + GetRealLevel(tch), + remort::GetRealRemort(tch), + MUD::Class(tch->GetClass()).GetAbbr(), + tch->get_pfilepos(), + GetPkNameColor(tch), + privilege::IsImmortal(tch.get()) ? kColorWht : "", tch->race_or_title(), kColorNrm); } else { - sprintf(buf, "%s %s%s%s", - GetPkNameColor(tch), - privilege::IsImmortal(tch.get()) ? kColorWht : "", tch->race_or_title().c_str(), kColorNrm); + line = fmt::format("{} {}{}{}", + GetPkNameColor(tch), + privilege::IsImmortal(tch.get()) ? kColorWht : "", tch->race_or_title(), kColorNrm); } if (GET_INVIS_LEV(tch)) - sprintf(buf + strlen(buf), " (i%d)", GET_INVIS_LEV(tch)); + line += fmt::format(" (i{})", GET_INVIS_LEV(tch)); else if (AFF_FLAGGED(tch, EAffect::kInvisible)) - sprintf(buf + strlen(buf), " (невидим%s)", grammar::SexEnding((tch)->get_sex(), 6)); + line += fmt::format(" (невидим{})", grammar::SexEnding((tch)->get_sex(), 6)); if (AFF_FLAGGED(tch, EAffect::kHide)) - strcat(buf, " (прячется)"); + line += " (прячется)"; if (AFF_FLAGGED(tch, EAffect::kDisguise)) - strcat(buf, " (маскируется)"); + line += " (маскируется)"; if (tch->IsFlagged(EPlrFlag::kMailing)) - strcat(buf, " (отправляет письмо)"); + line += " (отправляет письмо)"; else if (tch->IsFlagged(EPlrFlag::kWriting)) - strcat(buf, " (пишет)"); + line += " (пишет)"; if (tch->IsFlagged(EPrf::kNoHoller)) - sprintf(buf + strlen(buf), " (глух%s)", grammar::SexEnding((tch)->get_sex(), 1)); + line += fmt::format(" (глух{})", grammar::SexEnding((tch)->get_sex(), 1)); if (tch->IsFlagged(EPrf::kNoTell)) - sprintf(buf + strlen(buf), " (занят%s)", grammar::SexEnding((tch)->get_sex(), 6)); + line += fmt::format(" (занят{})", grammar::SexEnding((tch)->get_sex(), 6)); if (tch->IsFlagged(EPlrFlag::kMuted)) - sprintf(buf + strlen(buf), " (молчит)"); + line += " (молчит)"; if (tch->IsFlagged(EPlrFlag::kDumbed)) - sprintf(buf + strlen(buf), " (нем%s)", grammar::SexEnding((tch)->get_sex(), 6)); + line += fmt::format(" (нем{})", grammar::SexEnding((tch)->get_sex(), 6)); if (tch->IsFlagged(EPlrFlag::kKiller) == EPlrFlag::kKiller) - sprintf(buf + strlen(buf), "&R (ДУШЕГУБ)&n"); + line += "&R (ДУШЕГУБ)&n"; if ((privilege::IsImmortal(ch) || GET_GOD_FLAG(ch, EGf::kDemigod)) && !(tch)->player_specials->saved.NameGod && GetRealLevel(tch) <= kNameLevel) { - sprintf(buf + strlen(buf), " &W!НЕ ОДОБРЕНО!&n"); + line += " &W!НЕ ОДОБРЕНО!&n"; if (showname) { - sprintf(buf + strlen(buf), - "\r\nПадежи: %s/%s/%s/%s/%s/%s Email: &S%s&s Пол: %s", - GET_PAD(tch, 0), GET_PAD(tch, 1), GET_PAD(tch, 2), - GET_PAD(tch, 3), GET_PAD(tch, 4), GET_PAD(tch, 5), - GET_GOD_FLAG(ch, EGf::kDemigod) ? "скрыто" : GET_EMAIL(tch), - genders[static_cast(tch->get_sex())]); + line += fmt::format("\r\nПадежи: {}/{}/{}/{}/{}/{} Email: &S{}&s Пол: {}", + GET_PAD(tch, 0), GET_PAD(tch, 1), GET_PAD(tch, 2), + GET_PAD(tch, 3), GET_PAD(tch, 4), GET_PAD(tch, 5), + GET_GOD_FLAG(ch, EGf::kDemigod) ? "скрыто" : GET_EMAIL(tch), + genders[static_cast(tch->get_sex())]); } } if ((GetRealLevel(ch) == kLvlImplementator) && (NORENTABLE(tch))) - sprintf(buf + strlen(buf), " &R(В КРОВИ)&n"); + line += " &R(В КРОВИ)&n"; else if ((privilege::IsImmortal(ch) || ch->IsFlagged(EPrf::kCoderinfo)) && NAME_BAD(tch)) { - sprintf(buf + strlen(buf), " &Wзапрет %s!&n", GetNameById((tch)->player_specials->saved.NameIDGod).c_str()); + line += fmt::format(" &Wзапрет {}!&n", GetNameById((tch)->player_specials->saved.NameIDGod)); } if (privilege::IsGod(ch) && (GET_GOD_FLAG(tch, EGf::kAllowTesterMode))) - sprintf(buf + strlen(buf), " &G(ТЕСТЕР!)&n"); + line += " &G(ТЕСТЕР!)&n"; if (privilege::IsGod(ch) && (GET_GOD_FLAG(tch, EGf::kSkillTester))) - sprintf(buf + strlen(buf), " &G(СКИЛЛТЕСТЕР!)&n"); + line += " &G(СКИЛЛТЕСТЕР!)&n"; if (privilege::IsGod(ch) && (tch->IsFlagged(EPlrFlag::kAutobot))) - sprintf(buf + strlen(buf), " &G(БОТ!)&n"); + line += " &G(БОТ!)&n"; if (privilege::IsImmortal(tch.get())) - strcat(buf, kColorNrm); + line += kColorNrm; } // endif shortlist if (privilege::IsImmortal(tch.get())) { imms_num++; - imms += buf; + imms += line; if (!short_list || !(imms_num % 4)) { imms += "\r\n"; } } else if (GET_GOD_FLAG(tch, EGf::kDemigod) && (privilege::IsImmortal(ch) || ch->IsFlagged(EPrf::kCoderinfo) || GET_GOD_FLAG(tch, EGf::kDemigod))) { demigods_num++; - demigods += buf; + demigods += line; if (!short_list || !(demigods_num % 4)) { demigods += "\r\n"; } } else { morts_num++; - morts += buf; + morts += line; if (!short_list || !(morts_num % 4)) morts += "\r\n"; } @@ -296,19 +315,15 @@ void DoWho(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { out += "\r\nВсего:"; if (imms_num) { - sprintf(buf, " бессмертных %d", imms_num); - out += buf; + out += fmt::format(" бессмертных {}", imms_num); } if (demigods_num) { - sprintf(buf, " привилегированных %d", demigods_num); - out += buf; + out += fmt::format(" привилегированных {}", demigods_num); } if (all && morts_num) { - sprintf(buf, " смертных %d (видимых %d)", all, morts_num); - out += buf; + out += fmt::format(" смертных {} (видимых {})", all, morts_num); } else if (morts_num) { - sprintf(buf, " смертных %d", morts_num); - out += buf; + out += fmt::format(" смертных {}", morts_num); } out += ".\r\n"; From 71837f3d446726d479819045b6e2c499cc08edb9 Mon Sep 17 00:00:00 2001 From: stribog Date: Sat, 29 Aug 2026 19:48:42 +0200 Subject: [PATCH 2/3] =?UTF-8?q?refactor(setall):=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B1=D1=83=D1=84=D0=B5=D1=80=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=B7=20setall=20(#3807)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все сообщения бога собирались в глобальных buf1 и buf2, разбор аргументов -- в buf/buf1/buf2 через three_arguments. Теперь сообщения собирает fmt::format в std::string, разбор идёт через utils::ExtractFirstArgument. Регистр слов и чистка остатка -- на месте вызова: почта сверяется с player_table подстрокой, то есть с учётом регистра. Заодно чинится пропавшее сообщение. В ветке смены e-mail у офлайнового бессмертного стояло if (GetRealLevel(vict) >= kLvlGod) { it->second->out += buf1; -- но buf1 в этой ветке никто не заполнял: он очищался в начале итерации и оставался пустым. Бог не получал ни строчки о том, почему персонаж пропущен, во всех соседних ветках перед этим стоит текст "Персонаж %s бессмертный!". Ровно тот класс ошибок, ради которого затеян слой 1: с общим буфером "забыл заполнить" и "кто-то заполнил раньше" выглядят в коде одинаково. У half_chop в заголовке появилось предупреждение: остаток он копирует с оглядкой на kMaxStringLength, а one_argument внутри вообще не проверяет длину -- буфер меньшего размера передавать нельзя, в новом коде брать ExtractFirstArgument. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XUwDWDnYdXrJdvjDVd36QH --- src/engine/ui/cmd_god/do_set_all.cpp | 159 ++++++++++++++------------- src/utils/mud_string.h | 3 + 2 files changed, 88 insertions(+), 74 deletions(-) diff --git a/src/engine/ui/cmd_god/do_set_all.cpp b/src/engine/ui/cmd_god/do_set_all.cpp index b254ce472..b8ee630a5 100644 --- a/src/engine/ui/cmd_god/do_set_all.cpp +++ b/src/engine/ui/cmd_god/do_set_all.cpp @@ -8,7 +8,9 @@ #include "engine/ui/cmd_god/do_set_all.h" #include "utils/native_text.h" +#include "utils/utils_string.h" #include "engine/db/player_index.h" +#include #include "administration/karma.h" #include "engine/entities/char_data.h" @@ -54,7 +56,6 @@ void setall_inspect() { delete vict; return; } - buf1[0] = '\0'; is_online = 0; d_vict = DescriptorByUid(player_table[it->second->pos].uid()); if (d_vict) @@ -65,8 +66,8 @@ void setall_inspect() { if (it->second->type_req == kSetallFreeze) { if (is_online) { if (GetRealLevel(d_vict->character) >= kLvlGod) { - sprintf(buf1, "Персонаж %s бессмертный!\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } @@ -77,14 +78,14 @@ void setall_inspect() { } else { if (LoadPlayerCharacter(player_table[it->second->pos].name().c_str(), vict, ELoadCharFlags::kFindId | ELoadCharFlags::kNoCrcCheck) < 0) { - sprintf(buf1, "Ошибка загрузки персонажа: %s.\r\n", player_table[it->second->pos].name().c_str()); delete vict; - it->second->out += buf1; + it->second->out += fmt::format("Ошибка загрузки персонажа: {}.\r\n", + player_table[it->second->pos].name()); continue; } else { if (GetRealLevel(vict) >= kLvlGod) { - sprintf(buf1, "Персонаж %s бессмертный!\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } @@ -98,89 +99,90 @@ void setall_inspect() { } else if (it->second->type_req == kSetallEmail) { if (is_online) { if (GetRealLevel(d_vict->character) >= kLvlGod) { - sprintf(buf1, "Персонаж %s бессмертный!\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } strncpy(GET_EMAIL(d_vict->character), it->second->newmail, 127); *(GET_EMAIL(d_vict->character) + 127) = '\0'; - sprintf(buf2, - "Смена e-mail адреса персонажа %s с %s на %s.\r\n", - player_table[it->second->pos].name().c_str(), - player_table[it->second->pos].mail.c_str(), - it->second->newmail); - AddKarma(d_vict->character.get(), buf2, GET_NAME(imm_d->character)); - it->second->out += buf2; + const std::string mail_note = + fmt::format("Смена e-mail адреса персонажа {} с {} на {}.\r\n", + player_table[it->second->pos].name(), + player_table[it->second->pos].mail, + it->second->newmail); + AddKarma(d_vict->character.get(), mail_note.c_str(), GET_NAME(imm_d->character)); + it->second->out += mail_note; } else { if (LoadPlayerCharacter(player_table[it->second->pos].name().c_str(), vict, ELoadCharFlags::kFindId | ELoadCharFlags::kNoCrcCheck) < 0) { - sprintf(buf1, "Ошибка загрузки персонажа: %s.\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Ошибка загрузки персонажа: {}.\r\n", + player_table[it->second->pos].name()); delete vict; continue; } else { if (GetRealLevel(vict) >= kLvlGod) { - it->second->out += buf1; + // Раньше сюда уходил глобальный buf1, который в этой ветке никто не + // заполнял: бог получал пустоту вместо причины пропуска (issue #3807). + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } strncpy(GET_EMAIL(vict), it->second->newmail, 127); *(GET_EMAIL(vict) + 127) = '\0'; - sprintf(buf2, - "Смена e-mail адреса персонажа %s с %s на %s.\r\n", - player_table[it->second->pos].name().c_str(), - player_table[it->second->pos].mail.c_str(), - it->second->newmail); - it->second->out += buf2; - AddKarma(vict, buf2, GET_NAME(imm_d->character)); + const std::string mail_note = + fmt::format("Смена e-mail адреса персонажа {} с {} на {}.\r\n", + player_table[it->second->pos].name(), + player_table[it->second->pos].mail, + it->second->newmail); + it->second->out += mail_note; + AddKarma(vict, mail_note.c_str(), GET_NAME(imm_d->character)); vict->save_char(); } } } else if (it->second->type_req == kSetallPwd) { if (is_online) { if (GetRealLevel(d_vict->character) >= kLvlGod) { - sprintf(buf1, "Персонаж %s бессмертный!\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } Password::set_password(d_vict->character.get(), std::string(it->second->pwd)); - sprintf(buf2, "У персонажа %s изменен пароль (setall).", player_table[it->second->pos].name().c_str()); - it->second->out += buf2; - sprintf(buf1, "\r\n"); - it->second->out += buf1; - AddKarma(d_vict->character.get(), buf2, GET_NAME(imm_d->character)); + const std::string pwd_note = + fmt::format("У персонажа {} изменен пароль (setall).", player_table[it->second->pos].name()); + it->second->out += pwd_note; + it->second->out += "\r\n"; + AddKarma(d_vict->character.get(), pwd_note.c_str(), GET_NAME(imm_d->character)); } else { if (LoadPlayerCharacter(player_table[it->second->pos].name().c_str(), vict, ELoadCharFlags::kFindId | ELoadCharFlags::kNoCrcCheck) < 0) { - sprintf(buf1, "Ошибка загрузки персонажа: %s.\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Ошибка загрузки персонажа: {}.\r\n", + player_table[it->second->pos].name()); delete vict; continue; } if (GetRealLevel(vict) >= kLvlGod) { - sprintf(buf1, "Персонаж %s бессмертный!\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } Password::set_password(vict, std::string(it->second->pwd)); - std::string str = player_table[it->second->pos].name(); - native_text::capitalize_first(str); - sprintf(buf2, "У персонажа %s изменен пароль (setall).", player_table[it->second->pos].name().c_str()); - it->second->out += buf2; - sprintf(buf1, "\r\n"); - it->second->out += buf1; - AddKarma(vict, buf2, GET_NAME(imm_d->character)); + const std::string pwd_note = + fmt::format("У персонажа {} изменен пароль (setall).", player_table[it->second->pos].name()); + it->second->out += pwd_note; + it->second->out += "\r\n"; + AddKarma(vict, pwd_note.c_str(), GET_NAME(imm_d->character)); vict->save_char(); } } else if (it->second->type_req == kSetallHell) { if (is_online) { if (GetRealLevel(d_vict->character) >= kLvlGod) { - sprintf(buf1, "Персонаж %s бессмертный!\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } @@ -191,14 +193,14 @@ void setall_inspect() { } else { if (LoadPlayerCharacter(player_table[it->second->pos].name().c_str(), vict, ELoadCharFlags::kFindId | ELoadCharFlags::kNoCrcCheck) < 0) { - sprintf(buf1, "Ошибка загрузки персонажа: %s.\r\n", player_table[it->second->pos].name().c_str()); delete vict; - it->second->out += buf1; + it->second->out += fmt::format("Ошибка загрузки персонажа: {}.\r\n", + player_table[it->second->pos].name()); continue; } else { if (GetRealLevel(vict) >= kLvlGod) { - sprintf(buf1, "Персонаж %s бессмертный!\r\n", player_table[it->second->pos].name().c_str()); - it->second->out += buf1; + it->second->out += fmt::format("Персонаж {} бессмертный!\r\n", + player_table[it->second->pos].name()); delete vict; continue; } @@ -217,8 +219,7 @@ void setall_inspect() { Password::send_password(it->second->mail, it->second->pwd); gettimeofday(&stop, nullptr); timediff(&result, &stop, &it->second->start); - sprintf(buf1, "Всего найдено: %d.\r\n", it->second->found); - it->second->out += buf1; + it->second->out += fmt::format("Всего найдено: {}.\r\n", it->second->found); page_string(ch->desc, it->second->out); setall_inspect_list.erase(it->first); } @@ -236,72 +237,82 @@ void do_setall(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { return; } - argument = three_arguments(argument, buf, buf1, buf2); + // Разбор идёт через utils::ExtractFirstArgument -- строковый аналог one_argument, без + // глобальных buf/buf1/buf2 (issue #3807). Слова приводим к нижнему регистру сами: почта + // сверяется с player_table подстрокой, то есть с учётом регистра. + std::string remains = argument ? argument : ""; + std::string mail = utils::ExtractFirstArgument(remains, remains); + std::string action = utils::ExtractFirstArgument(remains, remains); + std::string param = utils::ExtractFirstArgument(remains, remains); + native_text::to_lower(mail); + native_text::to_lower(action); + native_text::to_lower(param); + utils::TrimLeft(remains); + const std::string &reason = remains; + SetAllInspReqPtr req(new setall_inspect_request); req->newmail = nullptr; req->mail = nullptr; req->reason = nullptr; req->pwd = nullptr; - if (!*buf) { + if (mail.empty()) { SendMsgToChar("Usage: setall \r\n", ch); return; } - if (!IsValidEmail(buf)) { + if (!IsValidEmail(mail.c_str())) { SendMsgToChar("Некорректный e-mail!\r\n", ch); return; } - if (!isname(buf1, "frozen email passwd hell")) { + if (!isname(action, "frozen email passwd hell")) { SendMsgToChar("Данное действие совершить нельзя.\r\n", ch); return; } - if (utils::IsAbbr(buf1, "frozen")) { - skip_spaces(&argument); - if (!argument || !*argument) { + if (utils::IsAbbr(action.c_str(), "frozen")) { + if (reason.empty()) { SendMsgToChar("Необходимо указать причину такой немилости.\r\n", ch); return; } - if (*buf2) times = atol(buf2); + if (!param.empty()) times = atol(param.c_str()); type_request = kSetallFreeze; req->freeze_time = times; - req->reason = strdup(argument); - } else if (utils::IsAbbr(buf1, "email")) { - if (!*buf2) { + req->reason = strdup(reason.c_str()); + } else if (utils::IsAbbr(action.c_str(), "email")) { + if (param.empty()) { SendMsgToChar("Укажите новый e-mail!\r\n", ch); return; } - if (!IsValidEmail(buf2)) { + if (!IsValidEmail(param.c_str())) { SendMsgToChar("Новый e-mail некорректен!\r\n", ch); return; } - req->newmail = strdup(buf2); + req->newmail = strdup(param.c_str()); type_request = kSetallEmail; - } else if (utils::IsAbbr(buf1, "passwd")) { - if (!*buf2) { + } else if (utils::IsAbbr(action.c_str(), "passwd")) { + if (param.empty()) { SendMsgToChar("Укажите новый пароль!\r\n", ch); return; } - req->pwd = strdup(buf2); + req->pwd = strdup(param.c_str()); type_request = kSetallPwd; - } else if (utils::IsAbbr(buf1, "hell")) { - skip_spaces(&argument); - if (!argument || !*argument) { + } else if (utils::IsAbbr(action.c_str(), "hell")) { + if (reason.empty()) { SendMsgToChar("Необходимо указать причину такой немилости.\r\n", ch); return; } - if (*buf2) times = atol(buf2); + if (!param.empty()) times = atol(param.c_str()); type_request = kSetallHell; req->freeze_time = times; - req->reason = strdup(argument); + req->reason = strdup(reason.c_str()); } else { SendMsgToChar("Какой-то баг. Вы эту надпись видеть не должны.\r\n", ch); return; } req->type_req = type_request; - req->mail = str_dup(buf); + req->mail = str_dup(mail.c_str()); req->pos = 0; req->found = 0; req->out = ""; diff --git a/src/utils/mud_string.h b/src/utils/mud_string.h index 3fc906351..b590d1315 100644 --- a/src/utils/mud_string.h +++ b/src/utils/mud_string.h @@ -33,6 +33,9 @@ T three_arguments(T argument, char *first_arg, char *second_arg, char *third_arg } /// Разделить строку на первое слово (arg1) и остаток (arg2). +/// ВНИМАНИЕ: остаток копируется с оглядкой на kMaxStringLength, а one_argument внутри вообще не +/// проверяет длину -- буфер меньшего размера передавать нельзя. В новом коде берите +/// utils::ExtractFirstArgument: тот же разбор на std::string, без фиксированных буферов (#3807). void half_chop(const char *string, char *arg1, char *arg2); /// Разбить строку на отдельные аргументы. From 2ce7597fad218bb735a5340ef160abf5eeb51cbb Mon Sep 17 00:00:00 2001 From: stribog Date: Sat, 29 Aug 2026 19:50:28 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(utils):=20ExtractFirstArgument=20--=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B8=D1=82=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбор, на который опираются оба файла выше, чинится сам. Разделителем считался только пробел, а one_argument режет по a_isspace. Табуляция между аргументами склеивала два поля в одно: "frozen\t30 спам" давал слово "frozen\t30". Достижимо не в теории -- эта же функция разбирает строку dgaffect прямо из текста триггера. Остаток начинался с пробелов: substr(pos + 1) пропускал ровно один. Каждый вызывающий дочищал сам -- в dg_misc через Trim, в setall пришлось дописать TrimLeft. Теперь остаток отдаётся подчищенным, как и делает skip_spaces внутри half_chop; лишние Trim у вызывающих становятся холостыми. Комментарий утверждал, что "если нет пробелов то строки пустые" -- на деле строка из одного слова возвращает это слово и пустой остаток. Заодно описано, чем функция НЕ является: one_argument вдобавок понижает регистр и пропускает служебные слова (in, from, with, the, on, at, to). Это разные задачи, а не недоделка, но при переносе кода с half_chop про регистр надо помнить. Алиасинг ExtractFirstArgument(str, str) сохранён и покрыт тестом: во всех ветках слово копируется до записи в remains, последнее чтение s идёт раньше присваивания. Одно изменение поведения: значение с ведущими пробелами теперь отдаётся без них. Задевает хранение переменных триггеров (dg_scripts), где строка перед разбором и так проходит Split с TrimLeft, -- пробелы после имени переменной были артефактом формата, а не данными. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XUwDWDnYdXrJdvjDVd36QH --- src/utils/utils_string.cpp | 26 +++++++++++----- src/utils/utils_string.h | 20 ++++++++++-- tests/utils.string.cpp | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/utils/utils_string.cpp b/src/utils/utils_string.cpp index 94ed80c98..8fc009d9a 100644 --- a/src/utils/utils_string.cpp +++ b/src/utils/utils_string.cpp @@ -1,4 +1,5 @@ -//#include "utils_string.h" +//#include +#include "utils_string.h" #include @@ -246,19 +247,28 @@ std::string FirstWordOnString(std::string s, std::string mask) { // пропускает ведущие пробелы, возвращает первое слово, в remains остаток после пробела // безопасно вызывать как ExtractFirstArgument(str, str) - нет проблем с алиасингом std::string ExtractFirstArgument(const std::string &s, std::string &remains) { - auto start = s.find_first_not_of(' '); + // Разделители те же, что у a_isspace: раньше резали только по пробелу, и табуляция между + // аргументами склеивала два поля в одно -- в триггерах это вполне достижимо. + static constexpr std::string_view kSpaces = " \t\r\n\v\f"; + + const auto start = s.find_first_not_of(kSpaces); if (start == std::string::npos) { remains.clear(); return {}; } - auto space_pos = s.find(' ', start); - if (space_pos != std::string::npos) { - std::string word = s.substr(start, space_pos - start); - remains = s.substr(space_pos + 1); + + const auto word_end = s.find_first_of(kSpaces, start); + if (word_end == std::string::npos) { + std::string word = s.substr(start); + remains.clear(); return word; } - std::string word = s.substr(start); - remains.clear(); + + std::string word = s.substr(start, word_end - start); + // Остаток отдаём без ведущих пробелов: прежний substr(word_end + 1) пропускал ровно один, и + // каждый вызывающий дочищал остаток сам. + const auto rest_begin = s.find_first_not_of(kSpaces, word_end); + remains = (rest_begin == std::string::npos) ? std::string() : s.substr(rest_begin); return word; } diff --git a/src/utils/utils_string.h b/src/utils/utils_string.h index 1071f7f57..f5b5ad74f 100644 --- a/src/utils/utils_string.h +++ b/src/utils/utils_string.h @@ -144,9 +144,23 @@ std::vector Split(const std::string s, char delimiter = ' '); */ std::vector SplitAny(const std::string s, std::string any); -// аналог one_argument для string -// s - разделяемая строка -// возвращает первое слово, в remains остаток, если нет пробелов то строки пустые +/** + * Отделить первое слово строки от остатка -- разбор аргументов на std::string, без фиксированных + * буферов (в отличие от one_argument и half_chop, которым нужен буфер на kMaxStringLength). + * + * Возвращает первое слово, в remains кладёт всё, что после него, уже без ведущих пробелов. + * Строка из одного слова даёт это слово и пустой остаток; пустая или из одних пробелов -- две + * пустые строки. Передавать одну и ту же переменную и как s, и как remains можно: слово + * копируется раньше, чем перезаписывается остаток. + * + * Разделителями считаются пробел, табуляция и переводы строки -- то же, что у a_isspace. + * + * ВАЖНО, чем это НЕ является: one_argument вдобавок понижает регистр слова и пропускает + * служебные слова (in, from, with, the, on, at, to -- см. fill_word). Здесь ни того, ни другого + * нет: слово возвращается как есть. При переносе кода с one_argument/half_chop понижайте регистр + * сами через native_text::to_lower, если дальше слово сравнивается с учётом регистра или уходит + * в данные. + */ std::string ExtractFirstArgument(const std::string &s, std::string &remains); // первое слово разделенное маской diff --git a/tests/utils.string.cpp b/tests/utils.string.cpp index 6d82832cb..bf65ac078 100644 --- a/tests/utils.string.cpp +++ b/tests/utils.string.cpp @@ -584,4 +584,68 @@ TEST(Utils_String, IsEquivalent_OrderMatters) EXPECT_FALSE(utils::IsEquivalent("wor hel", "hello big world")); } +// ===== ExtractFirstArgument ===== + +TEST(Utils_String, ExtractFirstArgument_SplitsWordAndRest) +{ + std::string rest; + EXPECT_EQ(utils::ExtractFirstArgument("frozen 30 за спам", rest), "frozen"); + EXPECT_EQ(rest, "30 за спам"); +} + +TEST(Utils_String, ExtractFirstArgument_TabIsASeparator) +{ + // Раньше резали только по пробелу, и табуляция склеивала два аргумента в один. + std::string rest; + EXPECT_EQ(utils::ExtractFirstArgument("frozen\t30 спам", rest), "frozen"); + EXPECT_EQ(rest, "30 спам"); +} + +TEST(Utils_String, ExtractFirstArgument_RestHasNoLeadingSpaces) +{ + std::string rest; + EXPECT_EQ(utils::ExtractFirstArgument("email Vasya@Mail.RU", rest), "email"); + EXPECT_EQ(rest, "Vasya@Mail.RU") << "остаток отдаётся уже подчищенным"; +} + +TEST(Utils_String, ExtractFirstArgument_SingleWordLeavesEmptyRest) +{ + std::string rest = "мусор"; + EXPECT_EQ(utils::ExtractFirstArgument(" одно ", rest), "одно"); + EXPECT_TRUE(rest.empty()); +} + +TEST(Utils_String, ExtractFirstArgument_EmptyInputGivesEmptyParts) +{ + std::string rest = "мусор"; + EXPECT_TRUE(utils::ExtractFirstArgument(" ", rest).empty()); + EXPECT_TRUE(rest.empty()); +} + +TEST(Utils_String, ExtractFirstArgument_KeepsCaseAndFillWords) +{ + // В отличие от one_argument: регистр не понижается, служебные слова не пропускаются. + std::string rest; + EXPECT_EQ(utils::ExtractFirstArgument("FROZEN xxx", rest), "FROZEN"); + EXPECT_EQ(utils::ExtractFirstArgument("to кто", rest), "to"); + EXPECT_EQ(rest, "кто"); +} + +TEST(Utils_String, ExtractFirstArgument_SameVariableForInputAndRest) +{ + // Основной способ применения: разбор по одному слову в цикле. + std::string remains = "dgaffect цель свойство"; + EXPECT_EQ(utils::ExtractFirstArgument(remains, remains), "dgaffect"); + EXPECT_EQ(utils::ExtractFirstArgument(remains, remains), "цель"); + EXPECT_EQ(utils::ExtractFirstArgument(remains, remains), "свойство"); + EXPECT_TRUE(remains.empty()); +} + +TEST(Utils_String, ExtractFirstArgument_KeepsMultibyteLettersIntact) +{ + std::string rest; + EXPECT_EQ(utils::ExtractFirstArgument("волчица съела кролика", rest), "волчица"); + EXPECT_EQ(rest, "съела кролика"); +} + // vim: ts=4 sw=4 tw=0 noet syntax=cpp :