Преглед на файлове

Initial release of stylist UI (#6446)

Fixes #3037

Thanks to @Balferian and @aleos89

Co-authored-by: Aleos <aleos89@users.noreply.github.com>
Lemongrass3110 преди 3 години
родител
ревизия
c22ef3f547

+ 2 - 2
conf/battle/client.conf

@@ -15,11 +15,11 @@ min_chat_delay: 0
 
 // Valid range of dyes and styles on the client.
 min_hair_style: 0
-max_hair_style: 27
+max_hair_style: 42
 min_hair_color: 0
 max_hair_color: 8
 min_cloth_color: 0 
-max_cloth_color: 4
+max_cloth_color: 7
 min_body_style: 0
 max_body_style: 1
 

+ 3 - 1
conf/msg_conf/map_msg.conf

@@ -876,7 +876,9 @@
 
 797: This command is unavailable to non-4th class.
 
-//798-799 free
+// @stylist
+798: This command requires packet version 2015-11-04 or newer.
+799: You have already opened the stylist UI.
 
 800: Dragon Knight
 801: Meister

+ 41 - 0
db/import-tmpl/stylist.yml

@@ -0,0 +1,41 @@
+# This file is a part of rAthena.
+#   Copyright(C) 2022 rAthena Development Team
+#   https://rathena.org - https://github.com/rathena
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+###########################################################################
+# Stylist Database
+###########################################################################
+#
+# Stylist Settings
+#
+###########################################################################
+# - Look                     Look that will be changed.
+#   Options                  Possible options to select from.
+#     - Index                Client side index of the option.
+#       Value                Value of the look (can also be an item name).
+#       CostsHuman:          Costs for human players.
+#           Price            Required zeny. (Default: 0)
+#           RequiredItem     Required item. (Default: None)
+#           RequiredItemBox  Required item box. (Default: None)
+#       CostsDoram:          Costs for doram players.
+#           Price            Required zeny. (Default: 0)
+#           RequiredItem     Required item. (Default: None)
+#           RequiredItemBox  Required item box. (Default: None)
+###########################################################################
+
+Header:
+  Type: STYLIST_DB
+  Version: 1

+ 5 - 4
db/re/item_db_etc.yml

@@ -21686,8 +21686,8 @@ Body:
       NoMail: true
       NoAuction: true
   - Id: 6707
-    AegisName: Jeremy_Beauty_Coupon
-    Name: Jeremy Beauty Coupon
+    AegisName: J_Shop_Coupon
+    Name: Cash Hair Coupon
     Type: Etc
     Buy: 10
     Weight: 10
@@ -24023,9 +24023,10 @@ Body:
     Type: Etc
     Buy: 10
   - Id: 6959
-    AegisName: aegis_6959
-    Name: Costume Change Ticket
+    AegisName: Costume_Ticket
+    Name: Costume Change Ticket 
     Type: Etc
+    Buy: 0
     Trade:
       Override: 100
       NoDrop: true

+ 68 - 0
db/re/item_db_usable.yml

@@ -39884,6 +39884,57 @@ Body:
       NoGuildStorage: true
       NoMail: true
       NoAuction: true
+  - Id: 16843
+    AegisName: C_New_Style_Box
+    Name: Beauty Gift Box
+    Type: Cash
+    Buy: 20
+    Weight: 10
+    Trade:
+      Override: 100
+      NoDrop: true
+      NoTrade: true
+      NoSell: true
+      NoCart: true
+      NoGuildStorage: true
+      NoMail: true
+      NoAuction: true
+    Script: |
+      getitem 7622,1;
+  - Id: 16854
+    AegisName: CCloth_Dye_Coupon_Box
+    Name: Clothing Dye Box
+    Type: Cash
+    Buy: 20
+    Weight: 10
+    Trade:
+      Override: 100
+      NoDrop: true
+      NoTrade: true
+      NoSell: true
+      NoCart: true
+      NoGuildStorage: true
+      NoMail: true
+      NoAuction: true
+    Script: |
+      getitem 6046,1;
+  - Id: 16855
+    AegisName: CCloth_Dye_Coupon2_Box
+    Name: Clothing Dye Orig Box
+    Type: Cash
+    Buy: 20
+    Weight: 10
+    Trade:
+      Override: 100
+      NoDrop: true
+      NoTrade: true
+      NoSell: true
+      NoCart: true
+      NoGuildStorage: true
+      NoMail: true
+      NoAuction: true
+    Script: |
+      getitem 6047,1;
   - Id: 16864
     AegisName: Siege_Map_Teleport_Scroll_Box_10
     Name: Siege Map Teleport Scroll Box(10)
@@ -42918,6 +42969,23 @@ Body:
       NoAuction: true
     Script: |
       getgroupitem(IG_Event_Almighty_Box_100);
+  - Id: 17336
+    AegisName: J_Shop_Coupon_Box
+    Name: Cash Hair Coupon Box
+    Type: Cash
+    Buy: 20
+    Weight: 10
+    Trade:
+      Override: 100
+      NoDrop: true
+      NoTrade: true
+      NoSell: true
+      NoCart: true
+      NoGuildStorage: true
+      NoMail: true
+      NoAuction: true
+    Script: |
+      getitem 6707,1;
   - Id: 17337
     AegisName: Holy_Spirit_Scroll
     Name: Holy Spirit Egg

+ 429 - 0
db/re/stylist.yml

@@ -0,0 +1,429 @@
+# This file is a part of rAthena.
+#   Copyright(C) 2022 rAthena Development Team
+#   https://rathena.org - https://github.com/rathena
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+###########################################################################
+# Stylist Database
+###########################################################################
+#
+# Stylist Settings
+#
+###########################################################################
+# - Look                     Look that will be changed.
+#   Options                  Possible options to select from.
+#     - Index                Client side index of the option.
+#       Value                Value of the look (can also be an item name).
+#       CostsHuman:          Costs for human players.
+#           Price            Required zeny. (Default: 0)
+#           RequiredItem     Required item. (Default: None)
+#           RequiredItemBox  Required item box. (Default: None)
+#       CostsDoram:          Costs for doram players.
+#           Price            Required zeny. (Default: 0)
+#           RequiredItem     Required item. (Default: None)
+#           RequiredItemBox  Required item box. (Default: None)
+###########################################################################
+
+Header:
+  Type: STYLIST_DB
+  Version: 1
+
+Body:
+  - Look: Hair_Color
+    Options:
+      - Index: -1
+        Value: 0
+        CostsHuman:
+            Price: 0
+        CostsDoram:
+            Price: 0
+      - Index: 1
+        Value: 1
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 2
+        Value: 2
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 3
+        Value: 3
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 4
+        Value: 4
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 5
+        Value: 5
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 6
+        Value: 6
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 7
+        Value: 7
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 8
+        Value: 8
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+  - Look: Hair
+    Options:
+      - Index: 1
+        Value: 1
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 2
+        Value: 2
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 3
+        Value: 3
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 4
+        Value: 4
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 5
+        Value: 5
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 6
+        Value: 6
+        CostsHuman:
+            Price: 100000
+        CostsDoram:
+            Price: 100000
+      - Index: 7
+        Value: 7
+        CostsHuman:
+            Price: 100000
+        CostDoram:
+            RequiredItem: J_Shop_Coupon2
+            RequiredItemBox: J_Shop_Coupon2
+      - Index: 8
+        Value: 8
+        CostsHuman:
+            Price: 100000
+        CostDoram:
+            RequiredItem: J_Shop_Coupon2
+            RequiredItemBox: J_Shop_Coupon2
+      - Index: 9
+        Value: 9
+        CostsHuman:
+            Price: 100000
+        CostDoram:
+            RequiredItem: J_Shop_Coupon2
+            RequiredItemBox: J_Shop_Coupon2
+      - Index: 10
+        Value: 10
+        CostsHuman:
+            Price: 100000
+        CostDoram:
+            RequiredItem: J_Shop_Coupon2
+            RequiredItemBox: J_Shop_Coupon2
+      - Index: 11
+        Value: 11
+        CostsHuman:
+            Price: 100000
+      - Index: 12
+        Value: 12
+        CostsHuman:
+            Price: 100000
+      - Index: 13
+        Value: 13
+        CostsHuman:
+            Price: 100000
+      - Index: 14
+        Value: 14
+        CostsHuman:
+            Price: 100000
+      - Index: 15
+        Value: 15
+        CostsHuman:
+            Price: 100000
+      - Index: 16
+        Value: 16
+        CostsHuman:
+            Price: 100000
+      - Index: 17
+        Value: 17
+        CostsHuman:
+            Price: 100000
+      - Index: 18
+        Value: 18
+        CostsHuman:
+            Price: 100000
+      - Index: 19
+        Value: 19
+        CostsHuman:
+            Price: 100000
+      - Index: 20
+        Value: 20
+        CostsHuman:
+            Price: 100000
+      - Index: 21
+        Value: 21
+        CostsHuman:
+            Price: 100000
+      - Index: 22
+        Value: 22
+        CostsHuman:
+            Price: 100000
+      - Index: 23
+        Value: 23
+        CostsHuman:
+            Price: 100000
+      - Index: 24
+        Value: 24
+        CostsHuman:
+            RequiredItem: New_Style_Coupon
+            RequiredItemBox: C_New_Style_Box
+      - Index: 25
+        Value: 25
+        CostsHuman:
+            RequiredItem: New_Style_Coupon
+            RequiredItemBox: C_New_Style_Box
+      - Index: 26
+        Value: 26
+        CostsHuman:
+            RequiredItem: New_Style_Coupon
+            RequiredItemBox: C_New_Style_Box
+      - Index: 27
+        Value: 27
+        CostsHuman:
+            RequiredItem: New_Style_Coupon
+            RequiredItemBox: C_New_Style_Box
+      - Index: 28
+        Value: 28
+        CostsHuman:
+            RequiredItem: J_Shop_Coupon
+            RequiredItemBox: J_Shop_Coupon_Box
+      - Index: 29
+        Value: 29
+        CostsHuman:
+            RequiredItem: J_Shop_Coupon
+            RequiredItemBox: J_Shop_Coupon_Box
+      - Index: 30
+        Value: 30
+        CostsHuman:
+            RequiredItem: J_Shop_Coupon2
+            RequiredItemBox: J_Shop_Coupon2
+      - Index: 31
+        Value: 31
+        CostsHuman:
+            RequiredItem: J_Shop_Coupon2
+            RequiredItemBox: J_Shop_Coupon2
+      - Index: 32
+        Value: 32
+        CostsHuman:
+            Price: 100000
+      - Index: 33
+        Value: 33
+        CostsHuman:
+            Price: 3000000
+      - Index: 34
+        Value: 34
+        CostsHuman:
+            Price: 3000000
+      - Index: 35
+        Value: 35
+        CostsHuman:
+            Price: 3000000
+      - Index: 36
+        Value: 36
+        CostsHuman:
+            Price: 3000000
+      - Index: 37
+        Value: 37
+        CostsHuman:
+            Price: 3000000
+      - Index: 38
+        Value: 38
+        CostsHuman:
+            Price: 3000000
+      - Index: 39
+        Value: 39
+        CostsHuman:
+            Price: 3000000
+      - Index: 40
+        Value: 40
+        CostsHuman:
+            Price: 3000000
+      - Index: 41
+        Value: 41
+        CostsHuman:
+            Price: 3000000
+      - Index: 42
+        Value: 42
+        CostsHuman:
+            Price: 3000000
+  - Look: Clothes_Color
+    Options:
+      - Index: 1
+        Value: 0
+        CostsHuman:
+            RequiredItem: Clothing_Dye_Coupon_II
+            RequiredItemBox: CCloth_Dye_Coupon2_Box
+        CostsDoram:
+            RequiredItem: Clothing_Dye_Coupon_II
+            RequiredItemBox: CCloth_Dye_Coupon2_Box
+      - Index: 2
+        Value: 2
+        CostsHuman:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+        CostsDoram:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+      - Index: 3
+        Value: 3
+        CostsHuman:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+        CostsDoram:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+      - Index: 4
+        Value: 4
+        CostsHuman:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+      - Index: 5
+        Value: 5
+        CostsHuman:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+      - Index: 6
+        Value: 6
+        CostsHuman:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+      - Index: 7
+        Value: 7
+        CostsHuman:
+            RequiredItem: Clothing_Dye_Coupon
+            RequiredItemBox: CCloth_Dye_Coupon_Box
+  - Look: Head_Top
+    Options:
+      - Index: 1
+        Value: Hat
+        CostsHuman:
+            Price: 1000
+        CostsDoram:
+            Price: 1000
+      - Index: 2
+        Value: Ribbon
+        CostsHuman:
+            Price: 800
+        CostsDoram:
+            Price: 800
+      - Index: 3
+        Value: Bandana
+        CostsHuman:
+            Price: 400
+        CostsDoram:
+            Price: 400
+  - Look: Head_Mid
+    Options:
+      - Index: 1
+        Value: One_Eyed_Glass
+        CostsHuman:
+            Price: 10000
+        CostsDoram:
+            Price: 10000
+      - Index: 2
+        Value: Sunglasses
+        CostsHuman:
+            Price: 5000
+        CostsDoram:
+            Price: 5000
+      - Index: 3
+        Value: Luxury_Sunglasses
+        CostsHuman:
+            Price: 24000
+        CostsDoram:
+            Price: 24000
+      - Index: 4
+        Value: Spinning_Eyes
+        CostsHuman:
+            Price: 20000
+        CostsDoram:
+            Price: 20000
+      - Index: 5
+        Value: Diver's_Goggles
+        CostsHuman:
+            Price: 3500
+        CostsDoram:
+            Price: 3500
+      - Index: 6
+        Value: Glasses
+        CostsHuman:
+            Price: 4000
+        CostsDoram:
+            Price: 4000
+      - Index: 7
+        Value: Eye_Bandage
+        CostsHuman:
+            Price: 1000
+        CostsDoram:
+            Price: 1000
+  - Look: Head_Bottom
+    Options:
+      - Index: 1
+        Value: Granpa_Beard
+        CostsHuman:
+            Price: 5000
+        CostsDoram:
+            Price: 5000
+  - Look: Body2
+    Options:
+      - Index: 1
+        Value: 0
+        CostsHuman:
+            RequiredItem: Costume_Ticket
+      - Index: 2
+        Value: 1
+        CostsHuman:
+            RequiredItem: Costume_Ticket

+ 47 - 0
db/stylist.yml

@@ -0,0 +1,47 @@
+# This file is a part of rAthena.
+#   Copyright(C) 2022 rAthena Development Team
+#   https://rathena.org - https://github.com/rathena
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+###########################################################################
+# Stylist Database
+###########################################################################
+#
+# Stylist Settings
+#
+###########################################################################
+# - Look                     Look that will be changed.
+#   Options                  Possible options to select from.
+#     - Index                Client side index of the option.
+#       Value                Value of the look (can also be an item name).
+#       CostsHuman:          Costs for human players.
+#           Price            Required zeny. (Default: 0)
+#           RequiredItem     Required item. (Default: None)
+#           RequiredItemBox  Required item box. (Default: None)
+#       CostsDoram:          Costs for doram players.
+#           Price            Required zeny. (Default: 0)
+#           RequiredItem     Required item. (Default: None)
+#           RequiredItemBox  Required item box. (Default: None)
+###########################################################################
+
+Header:
+  Type: STYLIST_DB
+  Version: 1
+
+Footer:
+  Imports:
+  - Path: db/re/stylist.yml
+    Mode: Renewal
+  - Path: db/import/stylist.yml

+ 8 - 0
doc/atcommands.txt

@@ -1237,6 +1237,14 @@ Note: This command requires packet version 2016-10-12 or newer.
 
 ---------------------------------------
 
+@stylist
+
+Opens the stylist user interface.
+
+Note: This command requires packet version 2015-11-04 or newer.
+
+---------------------------------------
+
 @request <message>
 
 Sends a message to all connected GMs (via the GM whisper system).

+ 8 - 0
doc/script_commands.txt

@@ -2897,6 +2897,14 @@ This feature requires 2016-10-12aRagexeRE or newer.
 
 ---------------------------------------
 
+*openstylist({<char id>})
+
+Opens the stylist UI for the attached player or the given character id.
+
+This feature requires packet version 2015-11-04 or newer.
+
+---------------------------------------
+
 *getareadropitem("<map name>",<x1>,<y1>,<x2>,<y2>,<item>)
 
 This function will count all the items with the specified ID number lying on the

+ 19 - 0
src/map/atcommand.cpp

@@ -10655,6 +10655,24 @@ ACMD_FUNC(refineui)
 #endif
 }
 
+ACMD_FUNC( stylist ){
+	nullpo_retr(-1, sd);
+
+#if PACKETVER < 20151104
+	clif_displaymessage( fd, msg_txt( sd, 798 ) ); // This command requires packet version 2015-11-04 or newer.
+	return -1;
+#else
+
+	if( sd->state.stylist_open ){
+		clif_displaymessage( fd, msg_txt( sd, 799 ) ); // You have already opened the stylist UI.
+		return -1;
+	}
+
+	clif_ui_open( sd, OUT_UI_STYLIST, 0 );
+	return 0;
+#endif
+}
+
 #include "../custom/atcommand.inc"
 
 /**
@@ -10975,6 +10993,7 @@ void atcommand_basecommands(void) {
 		ACMD_DEF2("completequest", quest),
 		ACMD_DEF2("checkquest", quest),
 		ACMD_DEF(refineui),
+		ACMD_DEFR(stylist, ATCMD_NOCONSOLE|ATCMD_NOAUTOTRADE),
 	};
 	AtCommandInfo* atcommand;
 	int i;

+ 173 - 0
src/map/clif.cpp

@@ -21509,6 +21509,13 @@ void clif_parse_changedress( int fd, struct map_session_data* sd ){
 void clif_ui_open( struct map_session_data *sd, enum out_ui_type ui_type, int32 data ){
 	nullpo_retv(sd);
 
+	// If the UI requires state tracking
+	switch( ui_type ){
+		case OUT_UI_STYLIST:
+			sd->state.stylist_open = true;
+			break;
+	}
+
 	int fd = sd->fd;
 
 	WFIFOHEAD(fd,packet_len(0xae2));
@@ -22364,6 +22371,172 @@ void clif_parse_unequipall( int fd, struct map_session_data* sd ){
 #endif
 }
 
+void clif_stylist_response( struct map_session_data* sd, bool failed ){
+#if PACKETVER >= 20151104
+	struct PACKET_ZC_STYLE_CHANGE_RES p = {};
+
+	p.PacketType = HEADER_ZC_STYLE_CHANGE_RES;
+	p.flag = failed;
+
+	clif_send( &p, sizeof( p ), &sd->bl, SELF );
+
+	if( !failed ){
+		sd->state.stylist_open = false;
+	}
+#endif
+}
+
+bool clif_parse_stylist_buy_sub( struct map_session_data* sd, _look look, int16 index ){
+	std::shared_ptr<s_stylist_list> list = stylist_db.find( look );
+
+	if( list == nullptr ){
+		return false;
+	}
+
+	std::shared_ptr<s_stylist_entry> entry = util::umap_find( list->entries, index );
+
+	if( entry == nullptr ){
+		return false;
+	}
+
+	std::shared_ptr<s_stylist_costs> costs;
+
+	if( ( sd->class_ & MAPID_BASEMASK ) == MAPID_SUMMONER ){
+		costs = entry->doram;
+	}else{
+		costs = entry->human;
+	}
+
+	if( costs == nullptr ){
+		return false;
+	}
+
+	if( sd->status.zeny < costs->price ){
+		return false;
+	}
+
+	int16 inventoryIndex = -1;
+
+	if( costs->requiredItem != 0 ){
+		inventoryIndex = pc_search_inventory( sd, costs->requiredItem );
+
+		if( inventoryIndex < 0 ){
+			// No other option
+			if( costs->requiredItemBox == 0 ){
+				return false;
+			}
+
+			// Check if the box that contains the item is in the inventory
+			inventoryIndex = pc_search_inventory( sd, costs->requiredItemBox );
+
+			// The box containing the item also does not exist
+			if( inventoryIndex < 0 ){
+				return false;
+			}
+		}
+	}else if( costs->requiredItemBox != 0 ){
+		inventoryIndex = pc_search_inventory( sd, costs->requiredItem );
+
+		if( inventoryIndex < 0 ){
+			return false;
+		}
+	}
+
+	if( inventoryIndex >= 0 && pc_delitem( sd, inventoryIndex, 1, 0, 0, LOG_TYPE_OTHER ) != 0 ){
+		return false;
+	}
+
+	if( costs->price > 0 && pc_payzeny( sd, costs->price, LOG_TYPE_OTHER, nullptr ) != 0 ){
+		return false;
+	}
+
+	switch( look ){
+		case LOOK_HAIR:
+		case LOOK_HAIR_COLOR:
+		case LOOK_CLOTHES_COLOR:
+		case LOOK_BODY2:
+			pc_changelook( sd, look, entry->value );
+			break;
+		case LOOK_HEAD_BOTTOM:
+		case LOOK_HEAD_MID:
+		case LOOK_HEAD_TOP: {
+			struct mail_message msg = {};
+
+			msg.dest_id = sd->status.char_id;
+			safestrncpy( msg.send_name, "Styling Shop", NAME_LENGTH );
+			safestrncpy( msg.title, "<MSG>2949</MSG>", MAIL_TITLE_LENGTH );
+			safestrncpy( msg.body, "<MSG>2950</MSG>", MAIL_BODY_LENGTH );
+
+			msg.item[0].nameid = entry->value;
+			msg.item[0].identify = 1;
+			msg.item[0].amount = 1;
+
+			msg.status = MAIL_NEW;
+			msg.type = MAIL_INBOX_NORMAL;
+			msg.timestamp = time( nullptr );
+
+			intif_Mail_send( 0, &msg );
+
+			} break;
+	}
+
+	return true;
+}
+
+void clif_parse_stylist_buy( int fd, struct map_session_data* sd ){
+#if PACKETVER >= 20151104
+#if PACKETVER >= 20180516
+	struct PACKET_CZ_REQ_STYLE_CHANGE2* p = (struct PACKET_CZ_REQ_STYLE_CHANGE2*)RFIFOP( fd, 0 );
+#else
+	struct PACKET_CZ_REQ_STYLE_CHANGE* p = (struct PACKET_CZ_REQ_STYLE_CHANGE*)RFIFOP( fd, 0 );
+#endif
+#endif
+	if( p->HeadPalette != 0 && !clif_parse_stylist_buy_sub( sd, LOOK_HAIR_COLOR, p->HeadPalette ) ){
+		clif_stylist_response( sd, true );
+		return;
+	}
+
+	if( p->HeadStyle != 0 && !clif_parse_stylist_buy_sub( sd, LOOK_HAIR, p->HeadStyle ) ){
+		clif_stylist_response( sd, true );
+		return;
+	}
+
+	if( p->BodyPalette != 0 && !clif_parse_stylist_buy_sub( sd, LOOK_CLOTHES_COLOR, p->BodyPalette ) ){
+		clif_stylist_response( sd, true );
+		return;
+	}
+
+	if( p->TopAccessory != 0 && !clif_parse_stylist_buy_sub( sd, LOOK_HEAD_TOP, p->TopAccessory ) ){
+		clif_stylist_response( sd, true );
+		return;
+	}
+
+	if( p->MidAccessory != 0 && !clif_parse_stylist_buy_sub( sd, LOOK_HEAD_MID, p->MidAccessory ) ){
+		clif_stylist_response( sd, true );
+		return;
+	}
+
+	if( p->BottomAccessory != 0 && !clif_parse_stylist_buy_sub( sd, LOOK_HEAD_BOTTOM, p->BottomAccessory ) ){
+		clif_stylist_response( sd, true );
+		return;
+	}
+
+#if PACKETVER >= 20180516
+	if( p->BodyStyle != 0 && ( sd->class_ & JOBL_THIRD ) != 0 && ( sd->class_ & JOBL_FOURTH ) == 0 && !clif_parse_stylist_buy_sub( sd, LOOK_BODY2, p->BodyStyle ) ){
+		clif_stylist_response( sd, true );
+		return;
+	}
+#endif
+
+	clif_stylist_response( sd, false );
+}
+
+void clif_parse_stylist_close( int fd, struct map_session_data* sd ){
+#if PACKETVER >= 20151104
+	sd->state.stylist_open = false;
+#endif
+}
+
 /*==========================================
  * Main client packet processing function
  *------------------------------------------*/

+ 1 - 0
src/map/clif.hpp

@@ -1135,6 +1135,7 @@ enum in_ui_type : int8 {
 };
 
 enum out_ui_type : int8 {
+	OUT_UI_STYLIST = 1,
 	OUT_UI_ATTENDANCE = 7
 };
 

+ 9 - 0
src/map/clif_packetdb.hpp

@@ -2273,6 +2273,11 @@
 	parseable_packet(0x0980,7,clif_parse_SelectCart,2,6); // CZ_SELECTCART
 #endif
 
+#if PACKETVER >= 20151104
+	parseable_packet( HEADER_CZ_REQ_STYLE_CHANGE, sizeof( PACKET_CZ_REQ_STYLE_CHANGE ), clif_parse_stylist_buy, 0 );
+	parseable_packet( HEADER_CZ_REQ_STYLE_CLOSE, sizeof( PACKET_CZ_REQ_STYLE_CLOSE ), clif_parse_stylist_close, 0 );
+#endif
+
 // 2016-03-02bRagexe
 #if PACKETVER >= 20160302
 	packet(0x0A51,34);
@@ -2394,6 +2399,10 @@
 	packet(0x0ADD, 22);
 #endif
 
+#if PACKETVER >= 20180516
+	parseable_packet( HEADER_CZ_REQ_STYLE_CHANGE2, sizeof( PACKET_CZ_REQ_STYLE_CHANGE2 ), clif_parse_stylist_buy, 0 );
+#endif
+
 #if PACKETVER_MAIN_NUM >= 20181002 || PACKETVER_RE_NUM >= 20181002 || PACKETVER_ZERO_NUM >= 20181010
 	parseable_packet( 0x0B10, sizeof( struct PACKET_CZ_START_USE_SKILL ), clif_parse_StartUseSkillToId, 0 );
 	parseable_packet( 0x0B11, sizeof( struct PACKET_CZ_STOP_USE_SKILL ), clif_parse_StopUseSkillToId, 0 );

+ 1 - 0
src/map/map-server.vcxproj

@@ -343,5 +343,6 @@
     <Copy SourceFiles="$(SolutionDir)db\import-tmpl\spellbook_db.yml" DestinationFolder="$(SolutionDir)db\import\" ContinueOnError="true" Condition="!Exists('$(SolutionDir)db\import\spellbook_db.yml')" />
     <Copy SourceFiles="$(SolutionDir)db\import-tmpl\statpoint.yml" DestinationFolder="$(SolutionDir)db\import\" ContinueOnError="true" Condition="!Exists('$(SolutionDir)db\import\statpoint.yml')" />
     <Copy SourceFiles="$(SolutionDir)db\import-tmpl\status_disabled.txt" DestinationFolder="$(SolutionDir)db\import\" ContinueOnError="true" Condition="!Exists('$(SolutionDir)db\import\status_disabled.txt')" />
+    <Copy SourceFiles="$(SolutionDir)db\import-tmpl\stylist.yml" DestinationFolder="$(SolutionDir)db\import\" ContinueOnError="true" Condition="!Exists('$(SolutionDir)db\import\stylist.yml')" />
   </Target>
 </Project>

+ 270 - 0
src/map/npc.cpp

@@ -121,6 +121,271 @@ struct script_event_s{
 // Holds pointers to the commonly executed scripts for speedup. [Skotlex]
 std::map<enum npce_event, std::vector<struct script_event_s>> script_event;
 
+const std::string StylistDatabase::getDefaultLocation(){
+	return std::string(db_path) + "/stylist.yml";
+}
+
+bool StylistDatabase::parseCostNode( std::shared_ptr<s_stylist_entry> entry, bool doram, const YAML::Node& node ){
+	std::shared_ptr<s_stylist_costs> costs = doram ? entry->doram : entry->human;
+	bool costs_exists = costs != nullptr;
+
+	if( !costs_exists ){
+		costs = std::make_shared<s_stylist_costs>();
+	}
+
+	if( this->nodeExists( node, "Price" ) ){
+		uint32 price;
+
+		if( !this->asUInt32( node, "Price", price ) ){
+			return false;
+		}
+
+		if( price > MAX_ZENY ){
+			this->invalidWarning( node["Price"], "stylist_parseCostNode: Price %u is too high, capping to MAX_ZENY...\n", price );
+			price = MAX_ZENY;
+		}
+
+		costs->price = price;
+	}else{
+		if( !costs_exists ){
+			costs->price = 0;
+		}
+	}
+
+	if( this->nodeExists( node, "RequiredItem" ) ){
+		std::string item;
+
+		if( !this->asString( node, "RequiredItem", item ) ){
+			return false;
+		}
+
+		std::shared_ptr<item_data> id = item_db.search_aegisname( item.c_str() );
+
+		if( id == nullptr ){
+			this->invalidWarning( node["RequiredItem"], "stylist_parseCostNode: Unknown item \"%s\"...\n", item.c_str() );
+			return false;
+		}
+
+		costs->requiredItem = id->nameid;
+	}else{
+		if( !costs_exists ){
+			costs->requiredItem = 0;
+		}
+	}
+
+	if( this->nodeExists( node, "RequiredItemBox" ) ){
+		std::string item;
+
+		if( !this->asString( node, "RequiredItemBox", item ) ){
+			return false;
+		}
+
+		std::shared_ptr<item_data> id = item_db.search_aegisname( item.c_str() );
+
+		if( id == nullptr ){
+			this->invalidWarning( node["RequiredItemBox"], "stylist_parseCostNode: Unknown item \"%s\"...\n", item.c_str() );
+			return false;
+		}
+
+		costs->requiredItemBox = id->nameid;
+	}else{
+		if( !costs_exists ){
+			costs->requiredItemBox = 0;
+		}
+	}
+
+	if( !costs_exists ){
+		if( doram ){
+			entry->doram = costs;
+		}else{
+			entry->human = costs;
+		}
+	}
+
+	return true;
+}
+
+uint64 StylistDatabase::parseBodyNode( const YAML::Node &node ){
+	if( !this->nodesExist( node, { "Look", "Options" } ) ){
+		return 0;
+	}
+
+	std::string look_str;
+
+	if( !this->asString( node, "Look", look_str ) ){
+		return 0;
+	}
+
+	int64 constant;
+
+	if( !script_get_constant( ( "LOOK_" + look_str ).c_str(), &constant ) ){
+		this->invalidWarning( node["Look"], "stylist_parseBodyNode: Invalid look %s.\n", look_str.c_str() );
+		return 0;
+	}
+
+	switch( constant ){
+		case LOOK_HEAD_TOP:
+		case LOOK_HEAD_MID:
+		case LOOK_HEAD_BOTTOM:
+		case LOOK_HAIR:
+		case LOOK_HAIR_COLOR:
+		case LOOK_CLOTHES_COLOR:
+		case LOOK_BODY2:
+			break;
+		default:
+			this->invalidWarning( node["Look"], "stylist_parseBodyNode: Unsupported look value \"%s\"...\n", look_str.c_str() );
+			return 0;
+	}
+
+	std::shared_ptr<s_stylist_list> list = this->find( (uint32)constant );
+	bool exists = list != nullptr;
+	uint64 count = 0;
+
+	if( !exists ){
+		list = std::make_shared<s_stylist_list>();
+		list->look = (uint16)constant;
+	}
+
+	for( const YAML::Node& optionNode : node["Options"] ){
+		int16 index;
+
+		if( !this->asInt16( optionNode, "Index", index ) ){
+			return 0;
+		}
+
+		if( index == 0 ){
+			this->invalidWarning( optionNode["Index"], "stylist_parseBodyNode: Unsupported index value \"%hd\"...\n", index );
+			return 0;
+		}
+
+		std::shared_ptr<s_stylist_entry> entry = util::umap_find( list->entries, index );
+		bool entry_exists = entry != nullptr;
+
+		if( !entry_exists ){
+			entry = std::make_shared<s_stylist_entry>();
+			entry->look = list->look;
+			entry->index = index;
+
+			if( !this->nodesExist( optionNode, { "Value" } ) ){
+				return 0;
+			}
+		}
+
+		if( this->nodeExists( optionNode, "Value" ) ){
+			uint32 value;
+
+			switch( list->look ){
+				case LOOK_HEAD_TOP:
+				case LOOK_HEAD_MID:
+				case LOOK_HEAD_BOTTOM: {
+						std::string item;
+
+						if( !this->asString( optionNode, "Value", item ) ){
+							return 0;
+						}
+
+						std::shared_ptr<item_data> id = item_db.search_aegisname( item.c_str() );
+
+						if( id == nullptr ){
+							this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: Unknown item \"%s\"...\n", item.c_str() );
+							return 0;
+						}
+
+						value = id->nameid;
+					} break;
+				case LOOK_HAIR:
+					if( !this->asUInt32( optionNode, "Value", value ) ){
+						return 0;
+					}
+
+					if( value < MIN_HAIR_STYLE ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: hair style \"%u\" is too low...\n", value );
+						return 0;
+					}else if( value > MAX_HAIR_STYLE ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: hair style \"%u\" is too high...\n", value );
+						return 0;
+					}
+					break;
+				case LOOK_HAIR_COLOR:
+					if( !this->asUInt32( optionNode, "Value", value ) ){
+						return 0;
+					}
+
+					if( value < MIN_HAIR_COLOR ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: hair color \"%u\" is too low...\n", value );
+						return 0;
+					}else if( value > MAX_HAIR_COLOR ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: hair color \"%u\" is too high...\n", value );
+						return 0;
+					}
+					break;
+				case LOOK_CLOTHES_COLOR:
+					if( !this->asUInt32( optionNode, "Value", value ) ){
+						return 0;
+					}
+
+					if( value < MIN_CLOTH_COLOR ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: cloth color \"%u\" is too low...\n", value );
+						return 0;
+					}else if( value > MAX_CLOTH_COLOR ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: cloth color \"%u\" is too high...\n", value );
+						return 0;
+					}
+					break;
+				case LOOK_BODY2:
+					if( !this->asUInt32( optionNode, "Value", value ) ){
+						return 0;
+					}
+
+					if( value < MIN_BODY_STYLE ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: body style \"%u\" is too low...\n", value );
+						return 0;
+					}else if( value > MAX_BODY_STYLE ){
+						this->invalidWarning( optionNode["Value"], "stylist_parseBodyNode: body style \"%u\" is too high...\n", value );
+						return 0;
+					}
+					break;
+			}
+
+			entry->value = value;
+		}
+
+		if( this->nodeExists( optionNode, "CostsHuman" ) ) {
+			if( !this->parseCostNode( entry, false, optionNode["CostsHuman"] ) ){
+				return 0;
+			}
+		}else{
+			if( !entry_exists ){
+				entry->human = nullptr;
+			}
+		}
+
+		if( this->nodeExists( optionNode, "CostsDoram" ) ) {
+			if( !this->parseCostNode( entry, true, optionNode["CostsDoram"] ) ){
+				return 0;
+			}
+		}else{
+			if( !entry_exists ){
+				entry->doram = nullptr;
+			}
+		}
+
+		if( !entry_exists ){
+			list->entries[index] = entry;
+		}
+
+		count++;
+	}
+
+	if( !exists ){
+		this->put( (uint32)constant, list );
+	}
+
+	return count;
+}
+
+StylistDatabase stylist_db;
+
 /**
  * Returns the viewdata for normal NPC classes.
  * @param class_: NPC class ID
@@ -4785,6 +5050,8 @@ int npc_reload(void) {
 		"\t-'" CL_WHITE "%d" CL_RESET "' Mobs Not Cached\n",
 		npc_id - npc_new_min, npc_warp, npc_shop, npc_script, npc_mob, npc_cache_mob, npc_delay_mob);
 
+	stylist_db.reload();
+
 	//Re-read the NPC Script Events cache.
 	npc_read_event_script();
 
@@ -4851,6 +5118,7 @@ void do_final_npc(void) {
 #if PACKETVER >= 20131223
 	NPCMarketDB->destroy(NPCMarketDB, npc_market_free);
 #endif
+	stylist_db.clear();
 	ers_destroy(timer_event_ers);
 	ers_destroy(npc_sc_display_ers);
 	npc_clearsrcfile();
@@ -4936,6 +5204,8 @@ void do_init_npc(void){
 		"\t-'" CL_WHITE "%d" CL_RESET "' Mobs Not Cached\n",
 		npc_id - START_NPC_NUM, npc_warp, npc_shop, npc_script, npc_mob, npc_cache_mob, npc_delay_mob);
 
+	stylist_db.load();
+
 	// set up the events cache
 	npc_read_event_script();
 

+ 34 - 0
src/map/npc.hpp

@@ -51,6 +51,40 @@ struct s_npc_buy_list {
 #pragma pack(pop)
 #endif // not NetBSD < 6 / Solaris
 
+struct s_stylist_costs{
+	uint32 price;
+	t_itemid requiredItem;
+	t_itemid requiredItemBox;
+};
+
+struct s_stylist_entry{
+	uint16 look;
+	int16 index;
+	uint32 value;
+	std::shared_ptr<s_stylist_costs> human;
+	std::shared_ptr<s_stylist_costs> doram;
+};
+
+struct s_stylist_list{
+	uint16 look;
+	std::unordered_map<int16, std::shared_ptr<s_stylist_entry>> entries;
+};
+
+class StylistDatabase : public TypesafeYamlDatabase<uint32, s_stylist_list>{
+private:
+	bool parseCostNode( std::shared_ptr<s_stylist_entry> entry, bool doram, const YAML::Node& node );
+
+public:
+	StylistDatabase() : TypesafeYamlDatabase( "STYLIST_DB", 1 ){
+
+	}
+
+	const std::string getDefaultLocation();
+	uint64 parseBodyNode( const YAML::Node& node );
+};
+
+extern StylistDatabase stylist_db;
+
 struct s_questinfo {
 	e_questinfo_types icon;
 	e_questinfo_markcolor color;

+ 8 - 0
src/map/packets.hpp

@@ -222,6 +222,10 @@ struct PACKET_CZ_UNCONFIRMED_RODEX_RETURN{
 	uint32 msgId;
 } __attribute__((packed));
 
+struct PACKET_CZ_REQ_STYLE_CLOSE{
+	int16 packetType;
+} __attribute__((packed));
+
 // NetBSD 5 and Solaris don't like pragma pack but accept the packed attribute
 #if !defined( sun ) && ( !defined( __NETBSD__ ) || __NetBSD_Version__ >= 600000000 )
 	#pragma pack( pop )
@@ -275,6 +279,10 @@ DEFINE_PACKET_HEADER(ZC_ACK_COUNT_BARGAIN_SALE_ITEM, 0x9c4)
 DEFINE_PACKET_HEADER(ZC_ACK_GUILDSTORAGE_LOG, 0x9da)
 DEFINE_PACKET_HEADER(CZ_NPC_MARKET_PURCHASE, 0x9d6)
 DEFINE_PACKET_HEADER(CZ_REQ_APPLY_BARGAIN_SALE_ITEM2, 0xa3d)
+DEFINE_PACKET_HEADER(CZ_REQ_STYLE_CHANGE, 0xa46)
+DEFINE_PACKET_HEADER(ZC_STYLE_CHANGE_RES, 0xa47)
+DEFINE_PACKET_HEADER(CZ_REQ_STYLE_CLOSE, 0xa48)
+DEFINE_PACKET_HEADER(CZ_REQ_STYLE_CHANGE2, 0xafc)
 DEFINE_PACKET_HEADER(ZC_REMOVE_EFFECT, 0x0b0d)
 DEFINE_PACKET_HEADER(CZ_UNCONFIRMED_TSTATUS_UP, 0x0b24)
 DEFINE_PACKET_HEADER(CZ_GUILD_EMBLEM_CHANGE2, 0x0b46)

+ 9 - 2
src/map/pc.hpp

@@ -382,6 +382,7 @@ struct map_session_data {
 		bool mail_writing; // Whether the player is currently writing a mail in RODEX or not
 		bool cashshop_open;
 		bool sale_open;
+		bool stylist_open;
 		unsigned int block_action : 10;
 		bool refineui_open;
 	} state;
@@ -1049,9 +1050,15 @@ extern JobDatabase job_db;
 #define pc_isidle_hom(sd)     ( (sd)->hd && ( (sd)->chatID || (sd)->state.vending || (sd)->state.buyingstore || DIFF_TICK(last_tick, (sd)->idletime_hom) >= battle_config.hom_idle_no_share ) )
 #define pc_isidle_mer(sd)     ( (sd)->md && ( (sd)->chatID || (sd)->state.vending || (sd)->state.buyingstore || DIFF_TICK(last_tick, (sd)->idletime_mer) >= battle_config.mer_idle_no_share ) )
 #define pc_istrading(sd)      ( (sd)->npc_id || (sd)->state.vending || (sd)->state.buyingstore || (sd)->state.trading )
+static bool pc_cant_act2( struct map_session_data* sd ){
+	return sd->state.vending || sd->state.buyingstore || (sd->sc.opt1 && sd->sc.opt1 != OPT1_BURNING)
+		|| sd->state.trading || sd->state.storage_flag || sd->state.prevend || sd->state.refineui_open
+		|| sd->state.stylist_open;
+}
 // equals pc_cant_act2 and additionally checks for chat rooms and npcs
-#define pc_cant_act(sd)       ( (sd)->npc_id || (sd)->chatID || pc_cant_act2( (sd) ) )
-#define pc_cant_act2(sd)      ( (sd)->state.vending || (sd)->state.buyingstore || ((sd)->sc.opt1 && (sd)->sc.opt1 != OPT1_BURNING) || (sd)->state.trading || (sd)->state.storage_flag || (sd)->state.prevend || (sd)->state.refineui_open )
+static bool pc_cant_act( struct map_session_data* sd ){
+	return sd->npc_id || sd->chatID || pc_cant_act2( sd );
+}
 
 #define pc_setdir(sd,b,h)     ( (sd)->ud.dir = (b) ,(sd)->head_dir = (h) )
 #define pc_setchatid(sd,n)    ( (sd)->chatID = n )

+ 18 - 0
src/map/script.cpp

@@ -25587,6 +25587,23 @@ BUILDIN_FUNC(mob_setidleevent){
 	return SCRIPT_CMD_SUCCESS;
 }
 
+BUILDIN_FUNC( openstylist ){
+#if PACKETVER >= 20151104
+	struct map_session_data* sd;
+
+	if( !script_charid2sd( 2, sd ) ){
+		return SCRIPT_CMD_FAILURE;
+	}
+
+	clif_ui_open( sd, OUT_UI_STYLIST, 0 );
+
+	return SCRIPT_CMD_SUCCESS;
+#else
+	ShowError( "buildin_openstylist: This command requires packet version 2015-11-04 or newer.\n" );
+	return SCRIPT_CMD_FAILURE;
+#endif
+}
+
 #include "../custom/script.inc"
 
 // declarations that were supposed to be exported from npc_chat.cpp
@@ -26291,6 +26308,7 @@ struct script_function buildin_func[] = {
 	BUILDIN_DEF(mob_setidleevent, "is"),
 
 	BUILDIN_DEF(setinstancevar,"rvi"),
+	BUILDIN_DEF(openstylist, "?"),
 #include "../custom/script_def.inc"
 
 	{NULL,NULL,NULL},