1
Fork 0
mirror of https://github.com/redstrate/Kawari.git synced 2025-05-05 20:27:45 +00:00

Implement Fantasia and remaking your character

Everyone's favorite copying mechanism/purchasable item is now functional
in Kawari. The item doesn't disappear once you use it, because there's
no API for that yet.
This commit is contained in:
Joshua Goins 2025-05-02 15:36:22 -04:00
parent cb146f173e
commit e7fb661244
10 changed files with 192 additions and 26 deletions

View file

@ -3,5 +3,10 @@ function onBeginLogin(player)
player:send_message("Welcome to Kawari!") player:send_message("Welcome to Kawari!")
end end
-- Actions
registerAction(3, "actions/Sprint.lua") registerAction(3, "actions/Sprint.lua")
registerAction(9, "actions/FastBlade.lua") registerAction(9, "actions/FastBlade.lua")
-- Items
registerAction(6221, "items/Fantasia.lua")

View file

@ -0,0 +1,8 @@
function doAction(player)
effects = EffectsBuilder()
-- TODO: match retail fantasia behavior
player:set_remake_mode("EditAppearance")
return effects
end

View file

@ -3,7 +3,7 @@ use std::net::SocketAddr;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use kawari::RECEIVE_BUFFER_SIZE; use kawari::RECEIVE_BUFFER_SIZE;
use kawari::common::workdefinitions::CharaMake; use kawari::common::workdefinitions::{CharaMake, RemakeMode};
use kawari::common::{GameData, ObjectId, timestamp_secs}; use kawari::common::{GameData, ObjectId, timestamp_secs};
use kawari::common::{Position, determine_initial_starting_zone}; use kawari::common::{Position, determine_initial_starting_zone};
use kawari::config::get_config; use kawari::config::get_config;
@ -712,8 +712,9 @@ async fn client_loop(
let lua = lua.lock().unwrap(); let lua = lua.lock().unwrap();
let state = lua.app_data_ref::<ExtraLuaState>().unwrap(); let state = lua.app_data_ref::<ExtraLuaState>().unwrap();
let key = request.action_key;
if let Some(action_script) = if let Some(action_script) =
state.action_scripts.get(&request.action_key) state.action_scripts.get(&key)
{ {
lua.scope(|scope| { lua.scope(|scope| {
let connection_data = scope let connection_data = scope
@ -745,6 +746,8 @@ async fn client_loop(
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
} else {
tracing::warn!("Action {key} isn't scripted yet! Ignoring...");
} }
} }
@ -1056,6 +1059,40 @@ async fn client_loop(
CustomIpcData::ImportCharacter { service_account_id, path } => { CustomIpcData::ImportCharacter { service_account_id, path } => {
database.import_character(*service_account_id, path); database.import_character(*service_account_id, path);
} }
CustomIpcData::RemakeCharacter { content_id, chara_make_json } => {
// overwrite it in the database
database.set_chara_make(*content_id, chara_make_json);
// reset flag
database.set_remake_mode(*content_id, RemakeMode::None);
// send response
{
send_packet::<CustomIpcSegment>(
&mut connection.socket,
&mut connection.state,
ConnectionType::None,
CompressionType::Uncompressed,
&[PacketSegment {
segment_type: SegmentType::KawariIpc,
data: SegmentData::KawariIpc {
data: CustomIpcSegment {
unk1: 0,
unk2: 0,
op_code: CustomIpcType::CharacterRemade,
option: 0,
timestamp: 0,
data: CustomIpcData::CharacterRemade {
content_id: *content_id,
},
},
},
..Default::default()
}],
)
.await;
}
}
_ => { _ => {
panic!("The server is recieving a response or unknown custom IPC!") panic!("The server is recieving a response or unknown custom IPC!")
} }

View file

@ -1,9 +1,10 @@
use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use crate::common::CustomizeData; use crate::common::CustomizeData;
// TODO: this isn't really an enum in the game, nor is it a flag either. it's weird! // TODO: this isn't really an enum in the game, nor is it a flag either. it's weird!
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, Deserialize, Serialize)]
#[repr(i32)] #[repr(i32)]
pub enum RemakeMode { pub enum RemakeMode {
/// No remake options are available. /// No remake options are available.
@ -14,6 +15,19 @@ pub enum RemakeMode {
EditAppearance = 4, EditAppearance = 4,
} }
impl TryFrom<i32> for RemakeMode {
type Error = ();
fn try_from(value: i32) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::None),
1 => Ok(Self::EditAppearanceName),
4 => Ok(Self::EditAppearance),
_ => Err(()),
}
}
}
/// See https://github.com/aers/FFXIVClientStructs/blob/main/FFXIVClientStructs/FFXIV/Application/Network/WorkDefinitions/ClientSelectData.cs /// See https://github.com/aers/FFXIVClientStructs/blob/main/FFXIVClientStructs/FFXIV/Application/Network/WorkDefinitions/ClientSelectData.cs
#[derive(Debug)] #[derive(Debug)]
pub struct ClientSelectData { pub struct ClientSelectData {
@ -38,8 +52,6 @@ pub struct ClientSelectData {
pub model_ids: [u32; 10], pub model_ids: [u32; 10],
pub equip_stain: [u32; 10], pub equip_stain: [u32; 10],
pub glasses: [u32; 2], pub glasses: [u32; 2],
pub unk15: i32,
pub unk16: i32,
pub remake_mode: RemakeMode, // TODO: upstream a comment about this to FFXIVClientStructs pub remake_mode: RemakeMode, // TODO: upstream a comment about this to FFXIVClientStructs
/// If above 0, then a message warns the user that they have X minutes left to remake their character. /// If above 0, then a message warns the user that they have X minutes left to remake their character.
pub remake_minutes_remaining: i32, pub remake_minutes_remaining: i32,
@ -71,8 +83,6 @@ impl ClientSelectData {
self.model_ids.map(|x| x.to_string()), self.model_ids.map(|x| x.to_string()),
self.equip_stain.map(|x| x.to_string()), self.equip_stain.map(|x| x.to_string()),
self.glasses.map(|x| x.to_string()), self.glasses.map(|x| x.to_string()),
self.unk15.to_string(),
self.unk16.to_string(),
(self.remake_mode as i32).to_string(), (self.remake_mode as i32).to_string(),
self.remake_minutes_remaining.to_string(), self.remake_minutes_remaining.to_string(),
self.voice_id.to_string(), self.voice_id.to_string(),

View file

@ -23,6 +23,8 @@ impl ReadWriteIpcSegment for CustomIpcSegment {
CustomIpcType::DeleteCharacter => 4, CustomIpcType::DeleteCharacter => 4,
CustomIpcType::CharacterDeleted => 1, CustomIpcType::CharacterDeleted => 1,
CustomIpcType::ImportCharacter => 132, CustomIpcType::ImportCharacter => 132,
CustomIpcType::RemakeCharacter => 1024 + 8,
CustomIpcType::CharacterRemade => 8,
} }
} }
} }
@ -54,6 +56,10 @@ pub enum CustomIpcType {
CharacterDeleted = 0x10, CharacterDeleted = 0x10,
/// Request to import a character backup /// Request to import a character backup
ImportCharacter = 0x11, ImportCharacter = 0x11,
/// Remake a character
RemakeCharacter = 0x12,
// Character has been remade
CharacterRemade = 0x13,
} }
#[binrw] #[binrw]
@ -117,6 +123,17 @@ pub enum CustomIpcData {
#[bw(map = write_string)] #[bw(map = write_string)]
path: String, path: String,
}, },
#[br(pre_assert(*magic == CustomIpcType::RemakeCharacter))]
RemakeCharacter {
content_id: u64,
#[bw(pad_size_to = 1024)]
#[br(count = 1024)]
#[br(map = read_string)]
#[bw(map = write_string)]
chara_make_json: String,
},
#[br(pre_assert(*magic == CustomIpcType::CharacterRemade))]
CharacterRemade { content_id: u64 },
} }
impl Default for CustomIpcData { impl Default for CustomIpcData {

View file

@ -9,6 +9,7 @@ pub enum ActionKind {
#[default] #[default]
Nothing = 0x0, Nothing = 0x0,
Normal = 0x1, Normal = 0x1,
Item = 0x2,
} }
#[binrw] #[binrw]

View file

@ -417,8 +417,6 @@ impl LobbyConnection {
let our_actor_id; let our_actor_id;
let our_content_id; let our_content_id;
dbg!(CharaMake::from_json(&character_action.json));
// tell the world server to create this character // tell the world server to create this character
{ {
let ipc_segment = CustomIpcSegment { let ipc_segment = CustomIpcSegment {
@ -537,7 +535,58 @@ impl LobbyConnection {
} }
LobbyCharacterActionKind::Move => todo!(), LobbyCharacterActionKind::Move => todo!(),
LobbyCharacterActionKind::RemakeRetainer => todo!(), LobbyCharacterActionKind::RemakeRetainer => todo!(),
LobbyCharacterActionKind::RemakeChara => todo!(), LobbyCharacterActionKind::RemakeChara => {
// tell the world server to turn this guy into a catgirl
{
let ipc_segment = CustomIpcSegment {
unk1: 0,
unk2: 0,
op_code: CustomIpcType::RemakeCharacter,
option: 0,
timestamp: 0,
data: CustomIpcData::RemakeCharacter {
content_id: character_action.content_id,
chara_make_json: character_action.json.clone(),
},
};
let _ = send_custom_world_packet(ipc_segment).await.unwrap();
// we intentionally don't care about the response right now, it's not expected to fail
}
// send a confirmation that the remakewas successful
{
let ipc = ServerLobbyIpcSegment {
unk1: 0,
unk2: 0,
op_code: ServerLobbyIpcType::CharaMakeReply,
option: 0,
timestamp: 0,
data: ServerLobbyIpcData::CharaMakeReply {
sequence: character_action.sequence + 1,
unk1: 0x1,
unk2: 0x1,
action: LobbyCharacterActionKind::RemakeChara,
details: CharacterDetails {
actor_id: 0, // TODO: fill maybe?
content_id: character_action.content_id,
character_name: character_action.name.clone(),
origin_server_name: self.world_name.clone(),
current_server_name: self.world_name.clone(),
..Default::default()
},
},
};
self.send_segment(PacketSegment {
segment_type: SegmentType::Ipc,
data: SegmentData::Ipc { data: ipc },
..Default::default()
})
.await;
}
}
LobbyCharacterActionKind::SettingsUploadBegin => todo!(), LobbyCharacterActionKind::SettingsUploadBegin => todo!(),
LobbyCharacterActionKind::SettingsUpload => todo!(), LobbyCharacterActionKind::SettingsUpload => todo!(),
LobbyCharacterActionKind::WorldVisit => todo!(), LobbyCharacterActionKind::WorldVisit => todo!(),

View file

@ -28,7 +28,7 @@ use crate::{
use super::{ use super::{
Actor, CharacterData, Event, Inventory, Item, LuaPlayer, StatusEffects, WorldDatabase, Zone, Actor, CharacterData, Event, Inventory, Item, LuaPlayer, StatusEffects, WorldDatabase, Zone,
inventory::Container, inventory::Container, lua::Task,
}; };
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
@ -623,7 +623,12 @@ impl ZoneConnection {
player.queued_segments.clear(); player.queued_segments.clear();
for task in &player.queued_tasks { for task in &player.queued_tasks {
self.change_zone(task.zone_id).await; match task {
Task::ChangeTerritory { zone_id } => self.change_zone(*zone_id).await,
Task::SetRemakeMode(remake_mode) => self
.database
.set_remake_mode(player.player_data.content_id, *remake_mode),
}
} }
player.queued_tasks.clear(); player.queued_tasks.clear();
} }

View file

@ -43,7 +43,7 @@ impl WorldDatabase {
// Create characters data table // Create characters data table
{ {
let query = "CREATE TABLE IF NOT EXISTS character_data (content_id INTEGER PRIMARY KEY, name STRING, chara_make STRING, city_state INTEGER, zone_id INTEGER, pos_x REAL, pos_y REAL, pos_z REAL, rotation REAL, inventory STRING);"; let query = "CREATE TABLE IF NOT EXISTS character_data (content_id INTEGER PRIMARY KEY, name STRING, chara_make STRING, city_state INTEGER, zone_id INTEGER, pos_x REAL, pos_y REAL, pos_z REAL, rotation REAL, inventory STRING, remake_mode INTEGER);";
connection.execute(query, ()).unwrap(); connection.execute(query, ()).unwrap();
} }
@ -239,16 +239,22 @@ impl WorldDatabase {
for (index, (content_id, actor_id)) in content_actor_ids.iter().enumerate() { for (index, (content_id, actor_id)) in content_actor_ids.iter().enumerate() {
let mut stmt = connection let mut stmt = connection
.prepare( .prepare(
"SELECT name, chara_make, zone_id, inventory FROM character_data WHERE content_id = ?1", "SELECT name, chara_make, zone_id, inventory, remake_mode FROM character_data WHERE content_id = ?1",
) )
.unwrap(); .unwrap();
let result: Result<(String, String, u16, String), rusqlite::Error> = stmt let result: Result<(String, String, u16, String, i32), rusqlite::Error> = stmt
.query_row((content_id,), |row| { .query_row((content_id,), |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
}); });
if let Ok((name, chara_make, zone_id, inventory_json)) = result { if let Ok((name, chara_make, zone_id, inventory_json, remake_mode)) = result {
let chara_make = CharaMake::from_json(&chara_make); let chara_make = CharaMake::from_json(&chara_make);
let inventory: Inventory = serde_json::from_str(&inventory_json).unwrap(); let inventory: Inventory = serde_json::from_str(&inventory_json).unwrap();
@ -273,9 +279,7 @@ impl WorldDatabase {
model_ids: inventory.get_model_ids(game_data), model_ids: inventory.get_model_ids(game_data),
equip_stain: [0; 10], equip_stain: [0; 10],
glasses: [0; 2], glasses: [0; 2],
unk15: 0, remake_mode: RemakeMode::try_from(remake_mode).unwrap(),
unk16: 0,
remake_mode: RemakeMode::None,
remake_minutes_remaining: 0, remake_minutes_remaining: 0,
voice_id: chara_make.voice_id, voice_id: chara_make.voice_id,
unk20: 0, unk20: 0,
@ -338,7 +342,7 @@ impl WorldDatabase {
// insert char data // insert char data
connection connection
.execute( .execute(
"INSERT INTO character_data VALUES (?1, ?2, ?3, ?4, ?5, 0.0, 0.0, 0.0, 0.0, ?6);", "INSERT INTO character_data VALUES (?1, ?2, ?3, ?4, ?5, 0.0, 0.0, 0.0, 0.0, ?6, 0);",
( (
content_id, content_id,
name, name,
@ -416,4 +420,24 @@ impl WorldDatabase {
.unwrap(); .unwrap();
stmt.execute((content_id,)).unwrap(); stmt.execute((content_id,)).unwrap();
} }
/// Sets the remake mode for a character
pub fn set_remake_mode(&self, content_id: u64, mode: RemakeMode) {
let connection = self.connection.lock().unwrap();
let mut stmt = connection
.prepare("UPDATE character_data SET remake_mode=?1 WHERE content_id = ?2")
.unwrap();
stmt.execute((mode as i32, content_id)).unwrap();
}
/// Sets the chara make JSON for a character
pub fn set_chara_make(&self, content_id: u64, chara_make_json: &str) {
let connection = self.connection.lock().unwrap();
let mut stmt = connection
.prepare("UPDATE character_data SET chara_make=?1 WHERE content_id = ?2")
.unwrap();
stmt.execute((chara_make_json, content_id)).unwrap();
}
} }

View file

@ -1,7 +1,7 @@
use mlua::{FromLua, Lua, LuaSerdeExt, UserData, UserDataMethods, Value}; use mlua::{FromLua, Lua, LuaSerdeExt, UserData, UserDataMethods, Value};
use crate::{ use crate::{
common::{ObjectId, ObjectTypeId, Position, timestamp_secs}, common::{ObjectId, ObjectTypeId, Position, timestamp_secs, workdefinitions::RemakeMode},
ipc::zone::{ ipc::zone::{
ActionEffect, ActorSetPos, DamageElement, DamageKind, DamageType, EffectKind, EventPlay, ActionEffect, ActorSetPos, DamageElement, DamageKind, DamageType, EffectKind, EventPlay,
ServerZoneIpcData, ServerZoneIpcSegment, ServerZoneIpcData, ServerZoneIpcSegment,
@ -12,8 +12,9 @@ use crate::{
use super::{PlayerData, StatusEffects, Zone}; use super::{PlayerData, StatusEffects, Zone};
pub struct ChangeTerritoryTask { pub enum Task {
pub zone_id: u16, ChangeTerritory { zone_id: u16 },
SetRemakeMode(RemakeMode),
} }
#[derive(Default)] #[derive(Default)]
@ -21,7 +22,7 @@ pub struct LuaPlayer {
pub player_data: PlayerData, pub player_data: PlayerData,
pub status_effects: StatusEffects, pub status_effects: StatusEffects,
pub queued_segments: Vec<PacketSegment<ServerZoneIpcSegment>>, pub queued_segments: Vec<PacketSegment<ServerZoneIpcSegment>>,
pub queued_tasks: Vec<ChangeTerritoryTask>, pub queued_tasks: Vec<Task>,
} }
impl LuaPlayer { impl LuaPlayer {
@ -100,7 +101,11 @@ impl LuaPlayer {
} }
fn change_territory(&mut self, zone_id: u16) { fn change_territory(&mut self, zone_id: u16) {
self.queued_tasks.push(ChangeTerritoryTask { zone_id }); self.queued_tasks.push(Task::ChangeTerritory { zone_id });
}
fn set_remake_mode(&mut self, mode: RemakeMode) {
self.queued_tasks.push(Task::SetRemakeMode(mode));
} }
} }
@ -132,6 +137,11 @@ impl UserData for LuaPlayer {
this.change_territory(zone_id); this.change_territory(zone_id);
Ok(()) Ok(())
}); });
methods.add_method_mut("set_remake_mode", |lua, this, mode: Value| {
let mode: RemakeMode = lua.from_value(mode).unwrap();
this.set_remake_mode(mode);
Ok(())
});
} }
} }