- Регистрация
- 12 Авг 2024
- Сообщения
- 54
- Реакции
- 33
- Баллы
- 105
- Сервер
-
- 1.4.6
Вступление
Внимание
Я ни в коем случае не поддерживаю нарушений пользовательского соглашения и правил сервера ComebackPW, а данное чтиво представлено исключительно в ознакомительных целях. Изложенная ниже информация не может быть использована для читерства, или извлечения любой другой выгодны нечестным путем на просторах идеального мира.Предисловие
Всем привет! Каждый игрок PW сам для себя определяет, что его держит в идеальном мире. Меня — ивент «Конкурс Ремесленников» и заточка снаряжения. Казалось бы, обе темы избитые и что еще про них еще можно написать? Ну, например, очередную заметку, разрушающую большинство мифов и суеверий, существующих вокруг этого китайского казиноОткуда исходный код?
Разумеется, у меня нет исходного кода ComebackPW. Но не думаю, что я кому-то открою Америку, если скажу, что любая фришка подымается на основе чистого клиент-сервера, которые можно найти в общем доступе на тематических форумах. Нужно понимать, что разработчики ComebackPW вполне могли изменить исходный код под свой проект, но я склонен думать, что это не так, по следующим причинам:- Трогать столь sensitive аспект игры довольно рискованно. Стандартная реализация (хоть и говнокод, простите), проверена десятилетиями и гарантирует отсутствие багов.
- Зачем трогать то, что и так отлично работает?
Люди десятки лет придумывает мифы вокруг точки, так что цель «удерживать игрока» достигается отлично.
Нужны ли мне какие-то математические/программистские знания, чтоб понять данную тему?
Надеюсь, нет. Я постарался написать ее так, чтоб она была понятна каждому. Однако, часть пояснений я скрыл под спойлеры, чтобы:- не нагружать деталями тех, кому они не нужны
- не объяснять лишний раз то, что кому-то может быть и так понятно в силу его профессии/образования/увлечений
Это такой тип приложения, который базируется на непрерывном взаимодействие двух программ. Первая — клиентская часть, распространяется на устройства юзеров, в нашем случае посредством лаунчера. На стороне клиента редко выполняется какая-то существенная логика (в первую очередь в целях безопасности). Обычно, клиентская часть отвечает за интерфейс в широком смысле этого понятия. Вторая программа — серверная часть. Сервер запускается на серверах (прошу прощения за тавтологию) и должен быть активным все время, чтобы игроки могли подключиться к нему через свой клиент. Клиент-серверное взаимодействие происходит непрерывно посредством отправки/получения пакетов с данными. Информация, разумеется, передается в зашифрованном виде.
Разберём на актуальном примере: пока вы идете к НИП-у, чтоб начать процесс заточки, со стороны клиента отправляется информация о движении персонажа. Как только на экране прогрузился НИП — это сервер прислал клиенту эту информацию исходя из местоположения персонажа, а он просто отрисовал ее. То же самое со взаимодействием с НИП-ом, списком предлагаемых им заданий и т.д. Когда уже открыто окно точки и вы выбираете предмет и камень улучшения — за это отвечает клиент. Как только нажата кнопка «Улучшение», отправляется запрос на сервер, где происходит вся логика (которую мы и будем с вами разбирать), а с получением ответа с результатом отображается результат и обновляются айтемы в инверторе.
Разберём на актуальном примере: пока вы идете к НИП-у, чтоб начать процесс заточки, со стороны клиента отправляется информация о движении персонажа. Как только на экране прогрузился НИП — это сервер прислал клиенту эту информацию исходя из местоположения персонажа, а он просто отрисовал ее. То же самое со взаимодействием с НИП-ом, списком предлагаемых им заданий и т.д. Когда уже открыто окно точки и вы выбираете предмет и камень улучшения — за это отвечает клиент. Как только нажата кнопка «Улучшение», отправляется запрос на сервер, где происходит вся логика (которую мы и будем с вами разбирать), а с получением ответа с результатом отображается результат и обновляются айтемы в инверторе.
Код v.s. Данные (Code v.s. Data)
Последнее, что я хотел бы пояснить перед тем, как переходить к тому, ради чего мы здесь сегодня собрались — объяснения концепции различия исходного кода и данных. Код должен был скомпилирован (в случае компилируемых языков программирования, каковым является C++, на котором написано PW).Процесс перевода конструкций языка программирования, понятных для человека, в бинарный код (состоящий из нулей и единиц), понятный для процессора. В результате создается исполняемый файл (.exe в случае Windows), который используется для запуска приложения.
Дата является более широким понятием и может представлять собой что угодно: таблицы, xml-файлы, ассеты (картинки), базы данных и т.д. Особенность даты заключается в том, что исходный код способен подхватить обновления данных «на лету». Таким образом, игроку даже не придется переоткрывать клиент (передаю привет мастерам, разбирающим .pck

Почему я счел необходимым это объяснить? Дело в том, что все item-ы, в том числе камни для повышения шансов заточки, являются данными, а значит шансы могут быть запросто «подкручены» без изменения исходного кода игры. Однако, как и прежде, я склонен верить, что на ComebackPW все шансы стандартные, тем более это ни один раз отмечалось администрацией сервера.
Чтение кода
gplayer_imp::RefineItemAddon
Процесс заточки инициируется этой функцией, вызов происходит при получение соответственного запроса с клиента. Она определена в файле player.cpp в классеgplayer_imp
, что расшифровывается как «global player implementation». Данный класс реализует множество логики, которая может быть выполнена персонажем в PW. «Refine» — дословно с английского «улучшить», «Addon» — сокр. от «Addition», в переводе с английского «дополнение». Ну, в процессе точке действительно мы улучшаем дополнения к характеристике/-ам предмета, так что все логично.
C++:
bool
gplayer_imp::RefineItemAddon(size_t index, int item_type, int rt_index)
{
if(index >= _inventory.Size()) return false;
item & it = _inventory[index];
if(it.type ==-1 || it.body == NULL || item_type != it.type ) return false;
if(rt_index >= 0 && (size_t)rt_index >= _inventory.Size()) return false;
int material_need = 0xFFFFF;
int refine_addon = world_manager::GetDataMan().get_item_refine_addon(item_type,material_need);
if(refine_addon <=0 || material_need <= 0) return false;
//检查幻仙石是否足够
int material_id = world_manager::GetDataMan().get_refine_meterial_id();
if(!_inventory.IsItemExist(material_id, material_need)) return false;
//检查概率调整装置是否存在
float adjust[4] = {0,0,0,0};
float adjust2[12] = {0,0,0,0,0,0,0,0,0,0,0,0};
int rt_id = -1;
if(rt_index >= 0)
{
rt_id = _inventory[rt_index].type;
if(rt_id <= 0) return false;
DATA_TYPE dt2;
const REFINE_TICKET_ESSENCE &ess= *(const REFINE_TICKET_ESSENCE*)world_manager::GetDataMan().get_data_ptr(rt_id, ID_SPACE_ESSENCE,dt2);
if(dt2 != DT_REFINE_TICKET_ESSENCE || &ess == NULL)
{
return false;
}
//限制装备天人合一
if(ess.binding_only && !(it.proc_type & item::ITEM_PROC_TYPE_BIND)) return false;
//限制装备品阶上限
if(ess.require_level_max && world_manager::GetDataMan().get_item_level(it.type) > ess.require_level_max) return false;
float adj1 = ess.ext_succeed_prob;
float adj2 = ess.ext_reserved_prob;
if(adj1 < 0) adj1 = 0;
if(adj2 < 0) adj2 = 0;
if(adj1 > 1.0) adj1 = 1.0;
if(adj2 > 1.0) adj2 = 1.0;
if(adj1 != ess.ext_reserved_prob || adj2 != ess.ext_succeed_prob)
{
__PRINTF("强化时发生状态调整\n");
}
adjust[0] = adj1; //成功概率
adjust[2] = adj2; //降一级概率
if(ess.fail_reserve_level )
{
adjust[1] = 2.0; //拥有特殊的保留石
}
for(size_t i =0; i < 12; i ++)
{
adjust2[i] = ess.fail_ext_succeed_prob[i];
}
}
int level_result = 0;
int rst = it.body->RefineAddon(refine_addon, level_result,adjust,adjust2);
if(rst != item::REFINE_CAN_NOT_REFINE)
{
const char * tbuf[] = {"成功", "无法精炼" , "材料消失", "属性降低一级", "属性爆掉", "装备爆掉","未知1","未知2","未知3"};
GLog::log(GLOG_INFO,"用户%d精炼物品%d[%s],精炼前级别%d 消耗幻仙石%d 概率物品%d",_parent->ID.id, item_type,tbuf[rst],level_result, material_need, rt_id);
if(level_result >= 6)
{
GLog::refine(_parent->ID.id,item_type, level_result, rst, material_need);
}
}
switch(rst)
{
case item::REFINE_CAN_NOT_REFINE:
//无法进行精炼,发送精炼失败 这种情况不作任何变化
_runner->error_message(S2C::ERR_REFINE_CAN_NOT_REFINE);
return true;
break;
case item::REFINE_SUCCESS:
//精炼成功
_runner->refine_result(0);
PlayerGetItemInfo(IL_INVENTORY,index);
break;
case item::REFINE_FAILED_LEVEL_0:
//精炼一级失败,删除材料
_runner->refine_result(1);
break;
default:
GLog::log(GLOG_ERR,"精炼装备时返回了异常错误%d",rst);
case item::REFINE_FAILED_LEVEL_1:
//精炼二级失败,删除材料,降低一级 属性已经被自动更改
_runner->refine_result(2);
PlayerGetItemInfo(IL_INVENTORY,index);
break;
case item::REFINE_FAILED_LEVEL_2:
//精炼三级失败,删除材料,属性已经被自动清除
_runner->refine_result(3);
PlayerGetItemInfo(IL_INVENTORY,index);
break;
}
//在这里删除物品
RemoveItems(material_id,material_need, S2C::DROP_TYPE_USE, true);
//如果使用了调整石,那么删除之
if(rt_index >= 0)
{
item& it = _inventory[rt_index];
UpdateMallConsumptionDestroying(it.type, it.proc_type, 1);
_inventory.DecAmount(rt_index, 1);
_runner->player_drop_item(IL_INVENTORY,rt_index,rt_id, 1 ,S2C::DROP_TYPE_USE);
}
return true;
}
Для начала давайте разберемся со входными параметрами функции (передаются с клиента):
index
– индекс улучшаемого предмета в инвентареitem_type
– тип предмета (оружие/бижутерия/броня и т.д.)rt_index
– индекс выбранного камня заточки в инвентаре (небесный камень, подземный камень, камень мироздания, жемчуг)
C++:
if(index >= _inventory.Size()) return false; // проверка, что индекс улучшаемого предмета не выходит за пределы размера инвентаря
item & it = _inventory[index]; // получение предмета, который подлежит улучшению
if(it.type ==-1 || it.body == NULL || item_type != it.type ) return false; // проверка, что предмет валидный
// проверка, что индекс камня заточки не выходит за пределы размера инвентаря (в случае использования)
if(rt_index >= 0 && (size_t)rt_index >= _inventory.Size()) return false;
// refine_addon - id улучшаемой характеристики; material_need - сколько миражей необходимо для данного типа предмета для попытки улучшения
int material_need = 0xFFFFF;
int refine_addon = world_manager::GetDataMan().get_item_refine_addon(item_type,material_need);
if(refine_addon <=0 || material_need <= 0) return false; // проверка, что у предмета существует улучшаемая посредством точки характеристика
// получение id предмета для заточки (миража, get_refine_meterial_id() возвращает 11208, что соответствует id камня бессмертного в базе данных pw)
int material_id = world_manager::GetDataMan().get_refine_meterial_id();
if(!_inventory.IsItemExist(material_id, material_need)) return false; // проверка наличия достаточного количества миражей в инвентаре
REFINE_SUCCESS
– успешная заточкаREFINE_FAILED_LEVEL_0
– неуспех, уровень заточки снаряжения не изменилсяREFINE_FAILED_LEVEL_1
– неуспех, уровень заточки снизился на 1REFINE_FAILED_LEVEL_2
– неуспех, уровень заточки сбросился до 0
C++:
/*
Ниже идет объявление двух массивов чисел, значение которых будут использованы для изменения шансов заточки.
adjust состоит из 4-х чисел, которые соответствует REFINE_SUCCESS REFINE_FAILED_LEVEL_0 REFINE_FAILED_LEVEL_1 REFINE_FAILED_LEVEL_2.
adjust2 состоит из 12-ти чисел, соответствующих изменению шанса успеха для каждого уровня заточки. Используется для камней мироздания и жемчуга.
*/
float adjust[4] = {0,0,0,0};
float adjust2[12] = {0,0,0,0,0,0,0,0,0,0,0,0};
int rt_id = -1;
if(rt_index >= 0) // используется камень улучшения
{
rt_id = _inventory[rt_index].type; // получения id камня улучшения из инвентаря по индексу
if(rt_id <= 0) return false; // проверка, что камень является валидным
/*
Далее идет запрос к "базе данных" для получения конкретных шансов при использовании камней для заточки.
Именно поэтому я выше обращал внимание на разницу между данными и кодом.
dt2 является выходным параметром функции get_data_ptr, в который записывается тип ответа.
ess является возвращаемым значением функции get_data_ptr. Эта структура типа REFINE_TICKET_ESSENCE.
Наиболее нас интересуют поля ext_reserved_prob, ext_succeed_prob, fail_reserve_level, fail_ext_succeed_prob,
которые соответствуют изменению шансов при заточке с использованием разных камней.
*/
DATA_TYPE dt2;
const REFINE_TICKET_ESSENCE &ess= *(const REFINE_TICKET_ESSENCE*)world_manager::GetDataMan().get_data_ptr(rt_id, ID_SPACE_ESSENCE,dt2);
if(dt2 != DT_REFINE_TICKET_ESSENCE || &ess == NULL) // проверка валидности возвращаемой структуры
{
return false;
}
// Проверка, что используемый камень заточки не предназначен только для заточки привязанного шмота в случае точки непривязанного предмета
if(ess.binding_only && !(it.proc_type & item::ITEM_PROC_TYPE_BIND)) return false;
// Проверка, что используемый камень заточки подходит для заточки предмета по уровню снаряжения
if(ess.require_level_max && world_manager::GetDataMan().get_item_level(it.type) > ess.require_level_max) return false;
/*
Данная операция называется «clamp» — обрезание значений изменения шансов заточки к определенному интервалу.
В простонародье — защита от дурака (мало ли что горе-сервер-мастера нарисуют в БД).
В данной реализации "1" означает 100% шанс, а "0" гарантирует неудачу.
*/
float adj1 = ess.ext_succeed_prob; // добавка к шансу успеха. Любое число от 0 до 1.
float adj2 = ess.ext_reserved_prob; // шанс того, что при неудаче будет сброшен только 1 уровень. Это значение может быть только 0 или 1.
if(adj1 < 0) adj1 = 0;
if(adj2 < 0) adj2 = 0;
if(adj1 > 1.0) adj1 = 1.0;
if(adj2 > 1.0) adj2 = 1.0;
adjust[0] = adj1; // сохранение корректировки для шанса успеха (REFINE_SUCCESS)
adjust[2] = adj2; // сохранение корректировки для шанса сброса на один уровень (REFINE_FAILED_LEVEL_1)
/*
fail_reserve_level используется для изменения шанса, что текущая точка будет сохранена при неуспехе (REFINE_FAILED_LEVEL_0),
т.е. может принимать значение тоже либо 0, либо 1, только при использовании камней мироздания или жемчуга.
*/
if(ess.fail_reserve_level )
{
adjust[1] = 2.0; // использование 2.0 здесь не имеет никакого смысла, 1.0 уже значит 100%-й шанс.
}
// сохранение корректировок шанса успеха на каждом уровне (используется только для камней мироздания или жемчуга)
for(size_t i =0; i < 12; i ++)
{
adjust2[i] = ess.fail_ext_succeed_prob[i];
}
}

RefineAddon
класса equip_item
. Сейчас нам важно отметь входные и выходные параметры:На вход:
refine_addon
– id улучшаемой характеристики. Не искал, где эти id-шники заданы в коде, но по сути это просто id для: м-деф/ф-деф/уклон для бижутерии, м-атака/ф-атака для колец и оружия, хп для остальногоadjust
– массив значений для корректировки шансов заточки, подробнее смотри комментарии к коду под спойлером "Изменение дефолтных шансов заточки"adjust2[12]
– массив значений для корректировки шансов заточки при использовании жемчуга или камней мироздания, подробнее смотри комментарии к коду под спойлером "Изменение дефолтных шансов заточки"
level_result
– уровень заточки снаряжения после попытки улучшенияrst
– возвращаемое значение. Результат заточки, соответствует исходам, определенным выше (REFINE_SUCCESS ... REFINE_FAILED_LEVEL_2
)
C++:
int level_result = 0; // определение выходного параметра уровня заточки после улучшения
// логика заточки тут, отдельно разберем в след главе. Метод вызывается у сущности предмета, который улучшаем, поэтому эту информацию передавать не надо.
int rst = it.body->RefineAddon(refine_addon, level_result,adjust,adjust2);
if(rst != item::REFINE_CAN_NOT_REFINE) // валидный результат
{
/*
Ниже просто две записи в журналы логов.
Машинный перевод: "Успех", "Невозможно усовершенствовать", "Материал исчез", "Атрибут уменьшен на один уровень", "Атрибут взорван",
"Оборудование взорвано", "Неизвестно 1", "Неизвестно 2", "Неизвестно 3"
*/
const char * tbuf[] = {"成功", "无法精炼" , "材料消失", "属性降低一级", "属性爆掉", "装备爆掉","未知1","未知2","未知3"};
GLog::log(GLOG_INFO,"用户%d精炼物品%d[%s],精炼前级别%d 消耗幻仙石%d 概率物品%d",_parent->ID.id, item_type,tbuf[rst],level_result, material_need, rt_id);
if(level_result >= 6) // дополнительная запись в другой журнал (файл), если точка выше 5
{
GLog::refine(_parent->ID.id,item_type, level_result, rst, material_need);
}
}
rst
(возвращаемое значение метода RefineAddon
), выполняется обмен данными между серверной частью приложения и игрой. Все вызовы функций, начинающихся с _runner
— это отправка данных на сторону клиента.
C++:
switch(rst)
{
case item::REFINE_CAN_NOT_REFINE: // обработка невалидного кейса: снаряжение не может быть улучшено
_runner->error_message(S2C::ERR_REFINE_CAN_NOT_REFINE);
return true;
break;
case item::REFINE_SUCCESS: // обработка успешного улучшение
_runner->refine_result(0);
/*
Насколько я понял, вызов PlayerGetItemInfo необходим для обновления характеристик персонажа на
стороне клиента. Внутри содержатся отправки сообщения для клиентской части игры.
*/
PlayerGetItemInfo(IL_INVENTORY,index);
break;
case item::REFINE_FAILED_LEVEL_0: // обработка случая, когда уровень заточки снаряжения не изменился
_runner->refine_result(1);
break;
default: // еще более невалидный кейс, чем REFINE_CAN_NOT_REFINE
GLog::log(GLOG_ERR,"精炼装备时返回了异常错误%d",rst);
case item::REFINE_FAILED_LEVEL_1: // обработка неуспешного улучшение со сбросом уровня заточки на 1
_runner->refine_result(2);
PlayerGetItemInfo(IL_INVENTORY,index); // см. выше
break;
case item::REFINE_FAILED_LEVEL_2: // обработка неуспешного улучшение со сбросом заточки в ноль
_runner->refine_result(3);
PlayerGetItemInfo(IL_INVENTORY,index); // см. выше
break;
}
RefineItemAddon
завершает попытку усовершенствования удалением используемых ресурсов, т.е. миража/-ей и камня улучшения, если таковой был применен.
C++:
// удаление камня бессмертных из инвентаря. Содержит в себе как серверную логику, так и отправку информации на сторону клиента
RemoveItems(material_id,material_need, S2C::DROP_TYPE_USE, true);
/*
Eсли использовался камень улучшения, то же самое делаем для него. По какой-то причине этот код не вынесен в отдельную функцию, в отличии от
RemoveItems, где первый входной параметр id предмета, а не его индекс в инвентаре
*/
if(rt_index >= 0)
{
item& it = _inventory[rt_index]; // получения предмета по индексу из инвентаря
/*
Вызов данной функции не имеет непосредственного отношения к теме улучшения снаряжения. Они разбросаны по всему коду,
как только изменяется количество айтемов в инвентаре. Я не стал разбираться, что скрывается под «Mall Consumption»
*/
UpdateMallConsumptionDestroying(it.type, it.proc_type, 1);
_inventory.DecAmount(rt_index, 1); // уменьшение количества камней улучшения на 1
_runner->player_drop_item(IL_INVENTORY,rt_index,rt_id, 1 ,S2C::DROP_TYPE_USE); // отправка сообщения об этом клиенту
}
equip_item::RefineAddon
. Пришло время заглянуть и ей «под капот».equip_item::RefineAddon
Если до этого мы разбирали метод класса «Player», то сейчас речь пойдет о методе класса «Equipment Item», то есть «предмет экипировки». Это означает, что теперь внутри функции доступны все поля (характеристики) класса улучшаемой экипировки. Как и ранее, сначала привожу полный код без комментариев.
C++:
struct refine_param_t
{
int need_level;
float prop[4]; //分别对应 成功
};
static refine_param_t refine_table[]=
{
{0 ,{ 0.50, 0.7, 0 ,0 }},
{1 ,{ 0.30, 0, 0 ,1 }},
{2 ,{ 0.30, 0, 0 ,1 }},
{3 ,{ 0.30, 0, 0 ,1 }},
{4 ,{ 0.30, 0, 0 ,1 }},
{5 ,{ 0.30, 0, 0 ,1 }},
{6 ,{ 0.30, 0, 0 ,1 }},
{7 ,{ 0.30, 0, 0 ,1 }},
{8 ,{ 0.25, 0, 0 ,1 }},
{9 ,{ 0.20, 0, 0 ,1 }},
{10,{ 0.12, 0, 0 ,1 }},
{11,{ 0.05, 0, 0 ,1 }},
};
static float refine_factor[] =
{
0, //not use
1.0f,
2.0f,
3.05f,
4.3f,
5.75f,
7.55f,
9.95f,
13.f,
17.05f,
22.3f,
29.f,
37.5f,
};
static int refine_failed_type[] =
{
item::REFINE_SUCCESS,
item::REFINE_FAILED_LEVEL_0,
item::REFINE_FAILED_LEVEL_1,
item::REFINE_FAILED_LEVEL_2,
};
int
equip_item::RefineAddon(int addon_id, int & level_result, float adjust[4], float adjust2[12])
{
//第一步寻找正确的addon内容
size_t addon_level = 0;
int addon_index = -1;
size_t count = _total_addon.size();
for(size_t i = 0; i < count; i ++)
{
addon_data & data = _total_addon[i];
int id = addon_manager::GetAddonID(data.id);
if(id == addon_id)
{
//得到已经升级后的数据
addon_index = i;
addon_level = data.arg[1];
break;
}
}
//超过或者达到最大的级别则不可升级
if(addon_level >= sizeof(refine_table) / sizeof(refine_param_t)) return item::REFINE_CAN_NOT_REFINE;
//保存原来的技能
level_result = addon_level;
//考虑概率
float prop[4];
memcpy(prop, refine_table[addon_level].prop,sizeof(prop));
ASSERT(sizeof(prop) == sizeof(refine_table[addon_level].prop));
//对prop进行修正
prop[0] += adjust[0]; prop[1] += adjust[1];
prop[2] += adjust[2]; prop[3] += adjust[3];
if(adjust[1] > 0)
{
//若特殊保留概率大于0 则使用adjust2里面带有的成功概率 并忽视原有的成功概率
prop[0] = adjust2[addon_level];
}
int rst = abase::RandSelect(prop, 4);
int failed_type = refine_failed_type[rst];
if(failed_type != item::REFINE_SUCCESS)
{
//未成功,考虑如何进行处理
switch(failed_type)
{
case item::REFINE_FAILED_LEVEL_0: //无变化
return failed_type;
case item::REFINE_FAILED_LEVEL_1: //装备降级 1级
//第一次就失败,装备无变化
if(addon_level == 0 || addon_index == -1) return item::REFINE_FAILED_LEVEL_0;
if(addon_level == 1)
{
//第二次失败,等同于归0
_total_addon.erase(_total_addon.begin() + addon_index);
OnRefreshItem();
return failed_type;
}
//其他情况 回到后面进行降级处理
break;
case item::REFINE_FAILED_LEVEL_2: //装备归0
if(addon_index != -1)
{
_total_addon.erase(_total_addon.begin() + addon_index);
OnRefreshItem();
}
return failed_type;
default:
ASSERT(false);
return failed_type;
}
}
addon_data newdata;
if(!world_manager::GetDataMan().generate_addon(addon_id,newdata)) return item::REFINE_CAN_NOT_REFINE;
if(addon_index == -1)
{
ASSERT(failed_type == item::REFINE_SUCCESS);
addon_level = 1;
//新生成一个addon
newdata.arg[0] = (int)(newdata.arg[0] * refine_factor[addon_level] + 0.1f);
newdata.arg[1] = 1; //当前级别为level1
_total_addon.push_back(newdata);
}
else
{
if(failed_type == item::REFINE_FAILED_LEVEL_1)
addon_level -= 1;
else
addon_level += 1;
_total_addon[addon_index].arg[0] = (int)(newdata.arg[0] * refine_factor[addon_level] + 0.1f);
_total_addon[addon_index].arg[1] = addon_level;
/* if(addon_manager::RefineAddonData(_total_addon[addon_index], newdata, failed_type == item::REFINE_FAILED_LEVEL_1) != 0)
{
return item::REFINE_CAN_NOT_REFINE;
}
*/
}
OnRefreshItem();
return failed_type;
}
Пошаговый разбор стоит начать с комментирования структуры и глобальных массивов, объявленных перед реализацией метода.
C++:
/*
Объявления структуры – нового типа данных, содержащей в себе два поля:
float prop[4] – массив вещественных чисел из 4-х элементов, соответствует шансам
исходов REFINE_SUCCESS, REFINE_FAILED_LEVEL_0, REFINE_FAILED_LEVEL_1, REFINE_FAILED_LEVEL_2.
Теоретически сумма вероятностей всех исходов должна всегда быть равна 1, но китайцы на это
забили и позднее будет понятно, почему это ничего не ломает в логике заточки.
int need_level – уровень заточки, к которому применимы данные шансы.
*/
struct refine_param_t
{
int need_level;
float prop[4];
};
/*
Объявление глобального массива с шансами исходов для каждого уровня заточки снаряжения без учета камней улучшения.
Т.е. ниже приведены шансы заточки миражами, которые ничем не отличаются от таблички шансов, впервые опубликованной на
https://pwinfo.fandom.com/ru/wiki/Заточка под заголовком "Шансы успеха заточки".
*/
static refine_param_t refine_table[]=
{
/*
Порядок шансов: REFINE_SUCCESS (+1), REFINE_FAILED_LEVEL_0 (+0), REFINE_FAILED_LEVEL_1 (-1), REFINE_FAILED_LEVEL_2 (0)
Еще раз отмечаю, что сумма вероятностей всех исходов должна быть равна 1, однако реализация RandomSelect ниже позволяет
указывать шансы после REFINE_SUCCESS любые. На самом же деле, для точки шансы с 0 -> +1,
шанс успех = 50%, шанс REFINE_FAILED_LEVEL_0 тоже 50% (а не 70%).
*/
{0 ,{ 0.50, 0.7, 0 ,0 }}, // шансы 0 -> +1: успех 50%, уровень не изменился 50%
{1 ,{ 0.30, 0, 0 ,1 }}, // шансы +1 -> +2: успех 30%, сброс в ноль 70%
{2 ,{ 0.30, 0, 0 ,1 }}, // шансы +2 -> +3: успех 30%, сброс в ноль 70%
{3 ,{ 0.30, 0, 0 ,1 }}, // шансы +3 -> +4: успех 30%, сброс в ноль 70%
{4 ,{ 0.30, 0, 0 ,1 }}, // шансы +4 -> +5: успех 30%, сброс в ноль 70%
{5 ,{ 0.30, 0, 0 ,1 }}, // шансы +5 -> +6: успех 30%, сброс в ноль 70%
{6 ,{ 0.30, 0, 0 ,1 }}, // шансы +6 -> +7: успех 30%, сброс в ноль 70%
{7 ,{ 0.30, 0, 0 ,1 }}, // шансы +7 -> +8: успех 30%, сброс в ноль 70%
{8 ,{ 0.25, 0, 0 ,1 }}, // шансы +8 -> +9: успех 25%, сброс в ноль 75%
{9 ,{ 0.20, 0, 0 ,1 }}, // шансы +9 -> +10: успех 20%, сброс в ноль 80%
{10,{ 0.12, 0, 0 ,1 }}, // шансы +10 -> +11: успех 12%, сброс в ноль 88%
{11,{ 0.05, 0, 0 ,1 }}, // шансы +11 -> +12: успех 5%, сброс в ноль 95%
};
/*
refine_factor используется для определения конечного улучшения на каждом уровне точки.
Известный факт, что буст к характеристики увеличиваете с повышением уровня заточки.
Например, добавка к здоровью для Безумного воина выглядит следующим образом:
1: +48
2: +96
3: +146
4: +206
5: +275
6: +362
7: +477
8: +624
9: +818
10: +1070
11: +1392
12: +1800
Базовое улучшение +48 хп.
Точка на +2 также добавляет 48хп (скейл-фактор 2), а +3 уже 50хп (скейл-фактор 3.05, 48 * 3.05 = 146),
Точка +12 уже добавляет аж 408 хп относительно точки +11 (48 * 37.5 = 1800)
*/
static float refine_factor[] =
{
0, //not use
1.0f,
2.0f,
3.05f,
4.3f,
5.75f,
7.55f,
9.95f,
13.f,
17.05f,
22.3f,
29.f,
37.5f,
};
/*
Объявление глобального массива, содержащего в себе все возможные исходы.
Этот массив будет использоваться для получения результата заточки после вызова функции рандома.
В данной реализации важен порядок элементов, ниже будет объяснено почему.
И да, название массива refine_failed_type с первым элементом REFINE_SUCCESS это класс :D
*/
static int refine_failed_type[] =
{
item::REFINE_SUCCESS, // улучшение +1
item::REFINE_FAILED_LEVEL_0, // нет улучшения, +0
item::REFINE_FAILED_LEVEL_1, // сброс -1
item::REFINE_FAILED_LEVEL_2, // сброс до 0
};
addon_id
– то, что раньше называлосьrefine_addon
— id улучшаемой характеристикиlevel_result
– выходной параметр (передается по ссылке), после завершения метода должен содержать в себе итоговый уровень заточки снаряжения (но не содержит, забыли китайцы присвоить значениет.к.
level_result
используется только для логирования, баг не нашли).adjust
– массив значений для корректировки шансов заточки, подробнее смотри комментарии к коду под спойлером «Изменение дефолтных шансов заточки»adjust2[12]
– массив значений для корректировки шансов заточки при использовании жемчуга или камней мироздания, подробнее смотри комментарии к коду под спойлером «Изменение дефолтных шансов заточки»

C++:
size_t addon_level = 0; // объявление искомых параметров — текущий уровень заточки
int addon_index = -1; // и индекс улучшения
size_t count = _total_addon.size(); // _total_addon — массив всех улучшений данной экипировки
for(size_t i = 0; i < count; i ++) // цикл по всем элементам массива
{
addon_data & data = _total_addon[i]; // получение структуры улучшения
int id = addon_manager::GetAddonID(data.id); // получение id рассматриваемого улучшения
if(id == addon_id) // проверка, является ли рассматриваемое улучшение искомым (улучшаемым)
{
addon_index = i; // сохранение индекса улучшаемой характеристики
addon_level = data.arg[1]; // сохранение текущего уровня точки
break;
}
}
/*
Очередная проверка невалидного кейса — текущий уровень снаряжения выше или равен 12.
Выражение sizeof(refine_table) / sizeof(refine_param_t) определяет размер массива refine_table,
который содержит 12 элементов с шансами заточки на каждом уровне.
В случае невалидного кейса происходит выход из функции и возврат REFINE_CAN_NOT_REFINE.
*/
if(addon_level >= sizeof(refine_table) / sizeof(refine_param_t)) return item::REFINE_CAN_NOT_REFINE;
/*
Если все ок, присваиваем выходному параметру текущий уровень точки.
Ниже после попытки улучшения level_result должен был бы быть обновлен, но это забыли сделать :D
*/
level_result = addon_level;
C++:
float prop[4]; // объявления массива итоговых вероятностей, будет передан в функцию RandSelect
memcpy(prop, refine_table[addon_level].prop,sizeof(prop)); // копирования вероятностей улучшения миражом для текущего уровня заточки из массива refine_table
ASSERT(sizeof(prop) == sizeof(refine_table[addon_level].prop)); // ASSERT — assertion (утверждение). Бесполезная проверка, всегда истина
// Корретировка шансов исходов при использовании камней улучшения
prop[0] += adjust[0]; // добавка к шансу REFINE_SUCCESS (+1), будет отличной от нуля при использовании Небесного или Подземного камня
prop[1] += adjust[1]; // добавка к шансу REFINE_FAILED_LEVEL_0 (+0), задана при использовании Камня мироздания и шаров, всегда 1 (100% шанс, если не успех)
prop[2] += adjust[2]; // добавка к шансу REFINE_FAILED_LEVEL_1 (-1), задана только при использовании Подземного камня, всегда 1 (100% шанс, если не успех)
prop[3] += adjust[3]; // добавка к шансу REFINE_FAILED_LEVEL_2 (0), всегда 0, не используется
/*
В случае, если вероятность REFINE_FAILED_LEVEL_0 отлична от нуля, т.е. используется шар или Камень мироздания, то стандартное значение вероятности успеха
улучшения переписывается значением из adjust2 (см. комментарии к коду под спойлером «Изменение дефолтных шансов заточки»).
Пусть X — текущий уровень заточки. Для шаров уровня > X, — шанс будет 1 (100%), иначе 0. Для мирозданок — соответствовать шансу из описания Камня мироздания.
*/
if(adjust[1] > 0)
{
prop[0] = adjust2[addon_level];
}
/*
«Сердце» процесса заточки — вызов функция рандома. На вход принимает массив вероятностей событий и его размер. Возвращает индекс событие, которое произошло.
Ниже я разберу реализацию функции RandSelect и тогда наконец-то станет понятно, почему суммарное значение вероятностей здесь может быть больше 1 и почему важен порядок:
REFINE_SUCCESS, REFINE_FAILED_LEVEL_0, REFINE_FAILED_LEVEL_1, REFINE_FAILED_LEVEL_2
*/
int rst = abase::RandSelect(prop, 4);
int failed_type = refine_failed_type[rst]; // получения результата заточки. Все еще ору, что имя переменной failed_type, хотя здесь может лежать REFINE_SUCCESS
RandSelect
, позвольте мне лирическое отступление.Прежде всего, в рамках данной статьи мы НЕ будем разбирать реализацию рандома. Функция
RandSelect
является лишь оберткой для генератора случайных чисел (ГСЧ), используемого в PW. Вообще, в исходном коде игры и нет этой реализации ГСЧ. Как и любая серьезный проект, игра была создана на основе движка. В случае PW это Angelica, информации о котором в интернете, кстати, не так много. Так вот, именно в коде движка Angelica можно найти реализацию ГСЧ. Я смог найти исходники движка на просторах интернета, но код там нечитаемый без опирания на статью, по которой рандом был реализован. К слову, статью я тоже нашел — оставлю ссылку здесь. Ее можно было бы изучить и понять, но вот только вряд ли в этом будет смысл Вынес в спойлер, т.к. факт известный. Дело в том, что в цифровом мире не существует настоящего рандома. Цифровой мир определенный, всегда можно зафиксировать текущее состояние и всегда можно предсказать следующее (вопрос в сложности), т.к. все запрограммировано. Благо исследователи давно придумали такие алгоритмы псевдорандома, что предсказать следующее значение исходя из предыдущих невозможно без использования супер-компьютеров. Но псевдорандом обладает еще одной особенностью — он будет выдавать одну и ту же последовательность чисел при перезапуске программы. Это можно избежать, если «стартовать» последовательность с рандомного места. Так что большинство ГСЧ, инициализируется «сидом» — числом, которое должно быть случайным. Какой-то замкнутый круг получается, не правда ли? С этой проблемой помогают бороться производители процессоров, внедряя в свои чипы аналоговые механизмы, которые способны в произвольный момент времени вернуть 0 или 1 действительно рандомно (на основе теплового шума). Насколько мне известно, напрямую этот рандом не используется для реализации ГСЧ, но вот для генерации сида для псевдорандомного алгоритма — в самый раз!
Главное, что надо понимать: несмотря на эту позорную приставку «псевдо», эти алгоритмы давным-давно себя зарекомендовали как надежный инструмент и используются почти во всех областях разработки программного обеспечения.
Главное, что надо понимать: несмотря на эту позорную приставку «псевдо», эти алгоритмы давным-давно себя зарекомендовали как надежный инструмент и используются почти во всех областях разработки программного обеспечения.
В реализации
Последовательность чисел, генерируемая ГСЧ, должна подчиняться какому-то закону. В статистике/теории вероятностей выделяют несколько наиболее часто используемых законов: нормальное (гауссовское) распределение, равномерное распределение, распределение Пуассона. Все они математически обоснованы, имеют четко установленные свойства и удивительно часто ими можно описать процессы в реальном мире. Равномерное распределение, пожалуй что, наиболее интуитивно понятно. В нем значения случайной величины имеют одинаковую вероятность, а математическое ожидание (среднее значение) равно половине промежутка допустимых значений. Наиболее часто приводимый пример из жизни процесса, подчиняющегося равномерному распределению — это подбрасывание монетки. Важно помнить, что в равномерном распределение вероятность значения случайной величины не зависит от истории, т.е. предыдущих результатов. Другими словами, если у вас чудом выпало 10 решек подряд, вероятность 11 решки все так же остается 50%. То же самое и в заточке снаряжения. Я понимаю, что сложно принять, что шанс 8-го плюса после 7-ми + подряд абсолютно такой же, как после 7-ми -, но это факт и если вас это ломает, почитайте еще статей на этот счет — я уверен, их полно. Ну и последнее, но тоже очень важное: все законы определены на бесконечности. Это еще один «трюк», который может быть не так понятен людям без профильного образования. Если говорить простым языком: генерируемые по закону равномерного распределения числа будут соответствовать его свойствам только при большом числе проводимых испытаний. Чем меньше опытов проведено, тем выше вероятность того, что текущая последовательность чисел действительно равномерна на заданном промежутке.
RandSelect
будет использоваться вызов функции RandomUniform
. Пару слов о равномерном распределении случайной величины, и как обычно, без определений из Википедии RandSelect
. В исходном коде представлено 2 реализации данной функции. Какая используется выбирается в зависимости отналичия или отсутствия директивы
#define __THREAD_SPEC_RAND__
(поточная спецификация рандома). Директива #define является параметры компиляции проекта. В зависимости от того, как будет собран проект, будет использоваться либо первая, либо вторая реализация. Разница между ними минимальная: в первом случае каждый поток имеет свой ГСЧ, в то время как во втором ГСЧ один на весь проект. Не смею предположить какая из реализаций используется на камбэке, да и узнать это невозможно. Но идейно они совершенно одинаковые.
C++:
#ifdef __THREAD_SPEC_RAND__
inline int RandSelect(const float * option, int size)
{
/*
В случае использования поточной спецификации, обращаемся к ГСЧ, который
является уникальным для данного потока. Запрашивается рандомное вещественное
число в диапазоне [0,1] (диапазон по умолчанию)
*/
float p = GetRandomGenInstance().RandomUniform();
/*
Этот цикл проверяет, к какому событию относится сгенерированное число.
Напомню, что на вход у нас массив из 4 вероятностей различных исходов.
Давайте рассмотрим корректные входные данные, где сумма вероятностей равна 1.
Массив на вход: [0.5, 0.3, 0.1, 0.1]. Представим это графически:
Имеется 4 промежутка, длина которых соответствует вероятностям:
1 2 3 4
[_ _ _ _ _][_ _ _][_][_]
Представим, что RandomUniform() сгенерировало число 0.3.
Т.к. 0.3 < 0.5, выбираем первый промежуток, т.е. произошло событие 1.
Представим, что RandomUniform() вернуло число 0.9.
Сначала проверяем, что это число не попало в первый промежуток:
0.9 > 0.5. Для проверки следующего промежутка, нам нужно вычесть из числа
размер первого промежутка, т.е. значение вероятности первого события:
0.9 - 0.5 = 0.4; 0.4 > 0.3 => снова вычитаем второй промежуток и
переходим к проверке третье промежутка: 0.4 - 0.3 = 0.1; 0.1 == 0.1.
Т.к. знак в условие ниже нестрогий, число попадет в третий промежуток,
а значит исходное число 0.9 соответствует тому, что произошло третье событие.
Если сумма вероятностей событий на вход >= 1, данный алгоритм гарантирует, что
будет выбран один из промежутков. Теперь, зная как реализован RandSelect, я
предлагаю вам самостоятельно понять, почему передача входных массивов вида
[0.5, 0.7, 0, 0] для +1 или [0.3, 0, 0, 1] для +2-+8 ничего не ломает в логике.
*/
for(int i = 0; i < size; i ++,option ++)
{
if(p <= *option) return i;
p -= *option;
}
/*
Любопытный факт: если выполнение кода дошло до этой строчки, значит входные
данные были невалидными, например, [0.3,0,0,0.5] при рандомном значении 0.9.
В этом случае не следовало бы возвращать 0, который для данного вызова RandSelect
является индексом события успешной заточки :D
*/
return 0;
}
#else
inline int RandSelect(const float * option, int size)
{
/*
Данная функция отличается от предыдущей только способом обращения к ГСЧ.
Т.к. в этой реализации используется глобальный ГСЧ __global_RandomGen, а игра
так или иначе запущена многопоточно, нам необходимо заблокировать ГСЧ для
остальных потоков, пока текущий взаимодействует с ним. Это классический
подход использования глобальных переменных в многопоточном программировании для
предотвращения класса проблем, известных под именем «data race».
*/
spin_autolock keeper(__global_RandomLock); // блокировка ГСЧ для других потоков
float p = __global_RandomGen.RandomUniform();
/* Тот же самый код, см. описание выше */
for(int i = 0; i < size; i ++,option ++)
{
if(p <= *option) return i;
p -= *option;
}
return 0;
} // разблокировка использования ГСЧ для других потоков происходит автоматически тут
#endif
equip_item::RefineAddon
идет блок обработки результата улучшения. При необходимости выполняется изменение параметров снаряжения.
C++:
/*
Cперва идут обработки всех случаев неудачного улучшения, кроме случая неудачи подземкой без сбития в ноль
(он будет обработан в следующей ветке).
*/
if(failed_type != item::REFINE_SUCCESS)
switch(failed_type)
{
case item::REFINE_FAILED_LEVEL_0: // улучшение не произошло, уровень не изменился (+0 или мирозданка)
return failed_type;
case item::REFINE_FAILED_LEVEL_1: // улучшение не произошло, уровень снижен на 1 (подземный камень)
if(addon_level == 0 || addon_index == -1) return item::REFINE_FAILED_LEVEL_0; // если точка была 0, то это кейс выше
if(addon_level == 1) // если точка была +1, необходимо удалить улучшаемую характеристику у предмета
{
_total_addon.erase(_total_addon.begin() + addon_index); // удаление характеристики
/*
Функция OnRefreshItem вызывается при каждом изменении характеристик снаряжения.
Тригерит обновление всех завязанных на снаряжение логик, таких как окно характеристик персонажа или хп-бар.
*/
OnRefreshItem();
return failed_type;
}
break; // если произошло уменьшение уровня заточки на 1, но при этом точка не сбита в ноль, нужно перейти во вторую часть функции
case item::REFINE_FAILED_LEVEL_2: // улучшение не произошло, уровень снаряжения сброшен в ноль (небеска или без камня улучшения)
if(addon_index != -1)
{
_total_addon.erase(_total_addon.begin() + addon_index); // удаление существующей улучшаемой характеристики у снаряжения
OnRefreshItem(); // см. выше
}
return failed_type;
default: // невалидные кейсы
ASSERT(false);
return failed_type;
}
}
/*
Далее идет обработки случаев, в результате которых на предмете остается улучшение и имеет место изменения его уровня,
т.е. успех или неудача подземным камнем при изначальной точке выше +1
*/
addon_data newdata;
if(!world_manager::GetDataMan().generate_addon(addon_id,newdata)) return item::REFINE_CAN_NOT_REFINE; // генерация нового улучшения
if(addon_index == -1) // эта проверка описывает случай, когда на предмете еще не существует улучшаемой характеристики (точка 0)
{
ASSERT(failed_type == item::REFINE_SUCCESS); // проверка, что попали в этот блок при успешной заточке
addon_level = 1; // сохранение нового уровня заточки снаряжения
newdata.arg[0] = (int)(newdata.arg[0] * refine_factor[addon_level] + 0.1f); // присваивание значения улучшения в соответствии с refine_factor
newdata.arg[1] = 1; // сохранение того же уровня заточки в объекте, описывающем улучшение
_total_addon.push_back(newdata); // добавление нового улучшения в список всех улучшений снаряжения
}
else // в этой ветке рассматриваются случаи, когда улучшаемый предмет уже обладает улучшаемой характеристикой
{
if(failed_type == item::REFINE_FAILED_LEVEL_1) // если неуспех подземкой (попадаем сюда из break switch конструкции в первой части)
addon_level -= 1; // снижаем текущий уровень на 1
else // наконец-то всеми желанный случай — успешное улучшение
addon_level += 1; // повышаем текущий уровень на 1
/*
Ниже идет корректировка существующих характеристик снаряжения в зависимости от результата заточки.
Изменяется величина добавки к характеристике и текущий уровень улучшения.
*/
_total_addon[addon_index].arg[0] = (int)(newdata.arg[0] * refine_factor[addon_level] + 0.1f);
_total_addon[addon_index].arg[1] = addon_level;
}
OnRefreshItem(); // см. выше
return failed_type; // возврат результата улучшения снаряжения в gplayer_imp::RefineItemAddon
gplayer_imp::RefineItemAddon
, т.е. мы переходим к коду, прокомментированному под спойлером «Вызов логики улучшения предмета и логирование результата».Выводы
Основываясь на исходный код, можно сформулировать следующие утверждения:- Т.к. ни в одну функция не передается история предыдущих кликов, о никаких «комбо» и речи быть не может. Так же как и зарекомендовавшие себя алгоритмы псевдорандома не имеют никаких тенденций, по которым вы могли бы предугадать, попадет ли следующее сканированное вещественное число в диапазон успеха ([0.0 - 0.45] для +2-+8 с с использованием Небесного камня).
- Здесь же сразу хочу напомнить, что ГСЧ неспецифичны для вашей сессии заточки. Он либо один на весь проект и генерирует тысячи чисел в единицу времени для разных логик игры, либо потоково-специфичный, что сократит нагрузку в N-потоков раз, но не отменит того факта, что вы однозначно не получаете следующее в последовательности число даже если кликаете кнопку улучшения снаряжения без какой-либо задержки.
- Процесс закрытие/открытия окна, смены локации, смены шмотки, перезахода в игру и любые другие манипуляции никак не влияют на результат улучшения. Каждый следующий клик является запуском логики полного цикла улучшения. Именно поэтому можете свободно кликать кнопку улучшения подряд до точки, которую хотите сделать. Это никак не уменьшит шанс успеха.
- Интересно, что если пренебречь временем, которое вы тратите на смену улучшаемой шмотки, то можно сказать, что по результату улучшения текущей шмотки можно было бы судить об успехе улучшения другой. Другими словами, обидное ощущение, которые вы испытываете при ситуация, когда уже хотели тыкнуть основную шмотку, выбивая комбо на левой, но все же в последний момент передумали и в результате увидели +, действительно имеет место быть
Однако с учетом затраты время на смену шмотки и установки небесного камня, рандом уже вернет не тот же самый результат, ну и шансы успеха на левой могут быть выше.
FAQ (будет дополняться)
- Вопрос: Прочитал полностью. Так и не понял, как мне на этом заработать?
Ответ: Быстро и надежно точно никак. Анализируйте таблицу шансов и рынок продажи переноса, оценивайте риски и вырабатывайте стратегию. Единственное, что могу посоветовать, используйте Небесный камень начиная с +3 (не +2) — это выгоднее приблизительно на 30%. Ну и не тратьте ресурсы на набивание комбхотя, с другой стороны, прокликивание левой шмотки можно рассмотреть как фарм следующей +3, что тоже ок, если вам так морально проще.
- Вопрос: Ты хоть сам веришь, что +12 можно заточить кликами подряд?
Ответ: Конечно верю! Ровно с тем же шансом, что и заточить +12 небесками любым другим способом. Шанс, к слову, 1.695 * 10⁻⁵, а математическое ожидание 60 000 кликов, т.е. около 49ккк по нынешней стоимости расходников. Это если кликать небом начиная с 0, стоимость существенно уменьшится, если тыкать небом с +3, но все еще не будет достаточно близкой к реальной цене +12 на серве (6.5ккк). Так что еще один совет к первому вопросу — точите перенос на продажу до +9. А в качестве пруфа реальности заточки кликами подряд, предлагаю ознакомиться с экспериментом Каменщика на поднятом сервере и с моим опытом точки на ComebackPW(бескрайняя благодарность брату за помощь в монтаже видео
). Фактически, было 8 подходов, количество миражей разнилось от 750 до 4000.
- Вопрос: У самого получается на этом зарабатывать?
Ответ: Да. Но детели раскрывать не хочу, дабы избежать лишней конкуренции.
Последнее редактирование: