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!")
end
-- Actions
registerAction(3, "actions/Sprint.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 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::{Position, determine_initial_starting_zone};
use kawari::config::get_config;
@ -712,8 +712,9 @@ async fn client_loop(
let lua = lua.lock().unwrap();
let state = lua.app_data_ref::<ExtraLuaState>().unwrap();
let key = request.action_key;
if let Some(action_script) =
state.action_scripts.get(&request.action_key)
state.action_scripts.get(&key)
{
lua.scope(|scope| {
let connection_data = scope
@ -745,6 +746,8 @@ async fn client_loop(
Ok(())
})
.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 } => {
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!")
}

View file

@ -1,9 +1,10 @@
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::common::CustomizeData;
// 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)]
pub enum RemakeMode {
/// No remake options are available.
@ -14,6 +15,19 @@ pub enum RemakeMode {
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
#[derive(Debug)]
pub struct ClientSelectData {
@ -38,8 +52,6 @@ pub struct ClientSelectData {
pub model_ids: [u32; 10],
pub equip_stain: [u32; 10],
pub glasses: [u32; 2],
pub unk15: i32,
pub unk16: i32,
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.
pub remake_minutes_remaining: i32,
@ -71,8 +83,6 @@ impl ClientSelectData {
self.model_ids.map(|x| x.to_string()),
self.equip_stain.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_minutes_remaining.to_string(),
self.voice_id.to_string(),

View file

@ -23,6 +23,8 @@ impl ReadWriteIpcSegment for CustomIpcSegment {
CustomIpcType::DeleteCharacter => 4,
CustomIpcType::CharacterDeleted => 1,
CustomIpcType::ImportCharacter => 132,
CustomIpcType::RemakeCharacter => 1024 + 8,
CustomIpcType::CharacterRemade => 8,
}
}
}
@ -54,6 +56,10 @@ pub enum CustomIpcType {
CharacterDeleted = 0x10,
/// Request to import a character backup
ImportCharacter = 0x11,
/// Remake a character
RemakeCharacter = 0x12,
// Character has been remade
CharacterRemade = 0x13,
}
#[binrw]
@ -117,6 +123,17 @@ pub enum CustomIpcData {
#[bw(map = write_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 {

View file

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

View file

@ -417,8 +417,6 @@ impl LobbyConnection {
let our_actor_id;
let our_content_id;
dbg!(CharaMake::from_json(&character_action.json));
// tell the world server to create this character
{
let ipc_segment = CustomIpcSegment {
@ -537,7 +535,58 @@ impl LobbyConnection {
}
LobbyCharacterActionKind::Move => 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::SettingsUpload => todo!(),
LobbyCharacterActionKind::WorldVisit => todo!(),

View file

@ -28,7 +28,7 @@ use crate::{
use super::{
Actor, CharacterData, Event, Inventory, Item, LuaPlayer, StatusEffects, WorldDatabase, Zone,
inventory::Container,
inventory::Container, lua::Task,
};
#[derive(Debug, Default, Clone)]
@ -623,7 +623,12 @@ impl ZoneConnection {
player.queued_segments.clear();
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();
}

View file

@ -43,7 +43,7 @@ impl WorldDatabase {
// 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();
}
@ -239,16 +239,22 @@ impl WorldDatabase {
for (index, (content_id, actor_id)) in content_actor_ids.iter().enumerate() {
let mut stmt = connection
.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();
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| {
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 inventory: Inventory = serde_json::from_str(&inventory_json).unwrap();
@ -273,9 +279,7 @@ impl WorldDatabase {
model_ids: inventory.get_model_ids(game_data),
equip_stain: [0; 10],
glasses: [0; 2],
unk15: 0,
unk16: 0,
remake_mode: RemakeMode::None,
remake_mode: RemakeMode::try_from(remake_mode).unwrap(),
remake_minutes_remaining: 0,
voice_id: chara_make.voice_id,
unk20: 0,
@ -338,7 +342,7 @@ impl WorldDatabase {
// insert char data
connection
.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,
name,
@ -416,4 +420,24 @@ impl WorldDatabase {
.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 crate::{
common::{ObjectId, ObjectTypeId, Position, timestamp_secs},
common::{ObjectId, ObjectTypeId, Position, timestamp_secs, workdefinitions::RemakeMode},
ipc::zone::{
ActionEffect, ActorSetPos, DamageElement, DamageKind, DamageType, EffectKind, EventPlay,
ServerZoneIpcData, ServerZoneIpcSegment,
@ -12,8 +12,9 @@ use crate::{
use super::{PlayerData, StatusEffects, Zone};
pub struct ChangeTerritoryTask {
pub zone_id: u16,
pub enum Task {
ChangeTerritory { zone_id: u16 },
SetRemakeMode(RemakeMode),
}
#[derive(Default)]
@ -21,7 +22,7 @@ pub struct LuaPlayer {
pub player_data: PlayerData,
pub status_effects: StatusEffects,
pub queued_segments: Vec<PacketSegment<ServerZoneIpcSegment>>,
pub queued_tasks: Vec<ChangeTerritoryTask>,
pub queued_tasks: Vec<Task>,
}
impl LuaPlayer {
@ -100,7 +101,11 @@ impl LuaPlayer {
}
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);
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(())
});
}
}