Skip to content

Commit 1255515

Browse files
committed
feat(replays): allow exporting as CSV
1 parent edb3ba0 commit 1255515

File tree

4 files changed

+232
-49
lines changed

4 files changed

+232
-49
lines changed

src/app.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,7 @@ impl ToolkitTabViewer<'_> {
199199
let previously_selected_format = *selected_format;
200200
egui::ComboBox::from_id_salt("auto_export_format_combobox").selected_text(selected_format.as_str()).show_ui(ui, |ui| {
201201
ui.selectable_value(selected_format, ReplayExportFormat::Json, "JSON");
202-
// CSV currently doesn't work. It's not a priority to fix, but should be explored at some point
203-
// ui.selectable_value(selected_format, ReplayExportFormat::Csv, "CSV");
202+
ui.selectable_value(selected_format, ReplayExportFormat::Csv, "CSV");
204203
ui.selectable_value(selected_format, ReplayExportFormat::Cbor, "CBOR");
205204
});
206205
if previously_selected_format != *selected_format {

src/replay_export.rs

Lines changed: 175 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use chrono::DateTime;
44
use chrono::Local;
55
use escaper::decode_html;
66
use serde::Serialize;
7+
use serde::Serializer;
78
use wows_replays::analyzer::battle_controller::BattleResult;
89
use wows_replays::analyzer::battle_controller::ChatChannel;
910
use wows_replays::analyzer::battle_controller::GameMessage;
@@ -20,9 +21,9 @@ use crate::ui::replay_parser::VehicleReport;
2021

2122
#[derive(Serialize)]
2223
pub struct Match {
23-
vehicles: Vec<Vehicle>,
24-
metadata: Metadata,
25-
game_chat: Vec<Message>,
24+
pub vehicles: Vec<Vehicle>,
25+
pub metadata: Metadata,
26+
pub game_chat: Vec<Message>,
2627
}
2728

2829
impl Match {
@@ -66,14 +67,6 @@ impl Match {
6667

6768
match_data
6869
}
69-
70-
pub fn vehicles(&self) -> &[Vehicle] {
71-
&self.vehicles
72-
}
73-
74-
pub fn metadata(&self) -> &Metadata {
75-
&self.metadata
76-
}
7770
}
7871

7972
#[derive(Serialize)]
@@ -124,6 +117,177 @@ impl From<&wows_replays::analyzer::battle_controller::Player> for Player {
124117
}
125118
}
126119

120+
fn serialize_option_vec<S>(opt_vec: &Option<Vec<String>>, serializer: S) -> Result<S::Ok, S::Error>
121+
where
122+
S: Serializer,
123+
{
124+
match opt_vec {
125+
Some(vec) => {
126+
let joined = vec.join(",");
127+
serializer.serialize_str(&joined)
128+
}
129+
None => serializer.serialize_none(),
130+
}
131+
}
132+
133+
#[derive(Serialize)]
134+
pub struct FlattenedVehicle {
135+
player_name: String,
136+
player_clan: String,
137+
player_id: i64,
138+
player_realm: String,
139+
/// Ship index that can be mapped to a GameParam
140+
index: String,
141+
/// Ship name from EN localization
142+
ship_name: String,
143+
/// Ship nation (e.g. "usa", "pan asia", etc.)
144+
ship_nation: String,
145+
/// Ship class
146+
ship_class: Species,
147+
/// Ship tier
148+
ship_tier: u32,
149+
/// Whether this is a test ship
150+
is_test_ship: bool,
151+
/// Whether this is an enemy
152+
is_enemy: bool,
153+
#[serde(serialize_with = "serialize_option_vec")]
154+
modules: Option<Vec<String>>,
155+
#[serde(serialize_with = "serialize_option_vec")]
156+
abilities: Option<Vec<String>>,
157+
/// Captain ID that can be mapped to a GameParam
158+
captain_id: Option<String>,
159+
#[serde(serialize_with = "serialize_option_vec")]
160+
captain_skills: Option<Vec<String>>,
161+
xp: Option<i64>,
162+
raw_xp: Option<i64>,
163+
damage: Option<u64>,
164+
ap: Option<u64>,
165+
sap: Option<u64>,
166+
he: Option<u64>,
167+
he_secondaries: Option<u64>,
168+
sap_secondaries: Option<u64>,
169+
torps: Option<u64>,
170+
deep_water_torps: Option<u64>,
171+
fire: Option<u64>,
172+
flooding: Option<u64>,
173+
spotting_damage: Option<u64>,
174+
potential_damage: Option<u64>,
175+
potential_damage_artillery: Option<u64>,
176+
potential_damage_torpedoes: Option<u64>,
177+
potential_damage_planes: Option<u64>,
178+
received_damage: Option<u64>,
179+
received_damage_ap: Option<u64>,
180+
received_damage_sap: Option<u64>,
181+
received_damage_he: Option<u64>,
182+
received_damage_he_secondaries: Option<u64>,
183+
received_damage_sap_secondaries: Option<u64>,
184+
received_damage_torps: Option<u64>,
185+
received_damage_deep_water_torps: Option<u64>,
186+
received_damage_fire: Option<u64>,
187+
received_damage_flooding: Option<u64>,
188+
fires_dealt: Option<u64>,
189+
floods_dealt: Option<u64>,
190+
citadels_dealt: Option<u64>,
191+
crits_dealt: Option<u64>,
192+
distance_traveled: Option<f64>,
193+
kills: Option<i64>,
194+
observed_damage: u64,
195+
observed_kills: i64,
196+
skill_points_allocated: Option<usize>,
197+
num_skills: Option<usize>,
198+
highest_tier_skill: Option<usize>,
199+
num_tier_1_skills: Option<usize>,
200+
time_lived_secs: Option<u64>,
201+
}
202+
203+
impl From<Vehicle> for FlattenedVehicle {
204+
fn from(value: Vehicle) -> Self {
205+
let Vehicle {
206+
player,
207+
index,
208+
name,
209+
nation,
210+
class,
211+
tier,
212+
is_test_ship,
213+
is_enemy,
214+
raw_config: _,
215+
translated_build,
216+
captain_id,
217+
server_results,
218+
observed_results,
219+
skill_meta_info,
220+
time_lived_secs,
221+
} = value;
222+
223+
let (modules, abilities, captain_skills) = if let Some(translated_config) = translated_build {
224+
let modules = translated_config.modules.iter().filter_map(|module| module.name.clone()).collect();
225+
let abilities = translated_config.abilities.iter().filter_map(|ability| ability.name.clone()).collect();
226+
let captain_skills = translated_config.captain_skills.map(|skills| skills.iter().filter_map(|skill| skill.name.clone()).collect());
227+
(Some(modules), Some(abilities), captain_skills)
228+
} else {
229+
(None, None, None)
230+
};
231+
Self {
232+
player_name: player.name,
233+
player_clan: player.clan,
234+
player_id: player.db_id,
235+
player_realm: player.realm,
236+
index,
237+
ship_name: name,
238+
ship_nation: nation,
239+
ship_class: class,
240+
ship_tier: tier,
241+
is_test_ship,
242+
is_enemy,
243+
modules,
244+
abilities,
245+
captain_id: Some(captain_id),
246+
captain_skills,
247+
xp: server_results.as_ref().map(|results| results.xp),
248+
raw_xp: server_results.as_ref().map(|results| results.raw_xp),
249+
damage: server_results.as_ref().map(|results| results.damage),
250+
ap: server_results.as_ref().and_then(|results| results.damage_details.ap),
251+
sap: server_results.as_ref().and_then(|results| results.damage_details.sap),
252+
he: server_results.as_ref().and_then(|results| results.damage_details.he),
253+
he_secondaries: server_results.as_ref().and_then(|results| results.damage_details.he_secondaries),
254+
sap_secondaries: server_results.as_ref().and_then(|results| results.damage_details.sap_secondaries),
255+
torps: server_results.as_ref().and_then(|results| results.damage_details.torps),
256+
deep_water_torps: server_results.as_ref().and_then(|results| results.damage_details.deep_water_torps),
257+
fire: server_results.as_ref().and_then(|results| results.damage_details.fire),
258+
flooding: server_results.as_ref().and_then(|results| results.damage_details.flooding),
259+
spotting_damage: server_results.as_ref().map(|results| results.spotting_damage),
260+
potential_damage: server_results.as_ref().map(|results| results.potential_damage),
261+
potential_damage_artillery: server_results.as_ref().map(|results| results.potential_damage_details.artillery),
262+
potential_damage_torpedoes: server_results.as_ref().map(|results| results.potential_damage_details.torpedoes),
263+
potential_damage_planes: server_results.as_ref().map(|results| results.potential_damage_details.planes),
264+
received_damage: server_results.as_ref().map(|results| results.received_damage),
265+
received_damage_ap: server_results.as_ref().and_then(|results| results.received_damage_details.ap),
266+
received_damage_sap: server_results.as_ref().and_then(|results| results.received_damage_details.sap),
267+
received_damage_he: server_results.as_ref().and_then(|results| results.received_damage_details.he),
268+
received_damage_he_secondaries: server_results.as_ref().and_then(|results| results.received_damage_details.he_secondaries),
269+
received_damage_sap_secondaries: server_results.as_ref().and_then(|results| results.received_damage_details.sap_secondaries),
270+
received_damage_torps: server_results.as_ref().and_then(|results| results.received_damage_details.torps),
271+
received_damage_deep_water_torps: server_results.as_ref().and_then(|results| results.received_damage_details.deep_water_torps),
272+
received_damage_fire: server_results.as_ref().and_then(|results| results.received_damage_details.fire),
273+
received_damage_flooding: server_results.as_ref().and_then(|results| results.received_damage_details.flooding),
274+
fires_dealt: server_results.as_ref().map(|results| results.fires_dealt),
275+
floods_dealt: server_results.as_ref().map(|results| results.floods_dealt),
276+
citadels_dealt: server_results.as_ref().map(|results| results.citadels_dealt),
277+
crits_dealt: server_results.as_ref().map(|results| results.crits_dealt),
278+
distance_traveled: server_results.as_ref().map(|results| results.distance_traveled),
279+
kills: server_results.as_ref().map(|results| results.kills),
280+
observed_damage: observed_results.as_ref().map(|results| results.damage).unwrap_or_default(),
281+
observed_kills: observed_results.as_ref().map(|results| results.kills).unwrap_or_default(),
282+
skill_points_allocated: skill_meta_info.as_ref().map(|info| info.skill_points),
283+
num_skills: skill_meta_info.as_ref().map(|info| info.num_skills),
284+
highest_tier_skill: skill_meta_info.as_ref().map(|info| info.highest_tier),
285+
num_tier_1_skills: skill_meta_info.as_ref().map(|info| info.num_tier_1_skills),
286+
time_lived_secs,
287+
}
288+
}
289+
}
290+
127291
#[derive(Serialize)]
128292
pub struct Vehicle {
129293
player: Player,

src/task.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ use crate::build_tracker;
4949
use crate::error::ToolkitError;
5050
use crate::game_params::load_game_params;
5151
use crate::plaintext_viewer::PlaintextFileViewer;
52+
use crate::replay_export::FlattenedVehicle;
5253
use crate::replay_export::Match;
5354
use crate::twitch::Token;
5455
use crate::twitch::TwitchState;
@@ -709,18 +710,21 @@ fn parse_replay_data_in_background(path: &Path, client: &reqwest::blocking::Clie
709710
ReplayExportFormat::Json => serde_json::to_writer(file, &transformed_data).context("failed to write export file"),
710711
ReplayExportFormat::Cbor => serde_cbor::to_writer(file, &transformed_data).context("failed to write export file"),
711712
ReplayExportFormat::Csv => {
712-
// TODO: this doesn't work
713-
let mut comment_data = Vec::new();
714-
let _ = csv::WriterBuilder::new().has_headers(true).from_writer(&mut comment_data).serialize(transformed_data.metadata());
715-
let mut writer = csv::WriterBuilder::new().has_headers(true).comment(Some(b'#')).from_writer(file);
713+
let mut writer = csv::WriterBuilder::new().has_headers(true).from_writer(file);
714+
let mut result = Ok(());
715+
for vehicle in transformed_data.vehicles {
716+
result = writer.serialize(FlattenedVehicle::from(vehicle));
717+
if result.is_err() {
718+
break;
719+
}
720+
}
716721

717-
let _ = writer.write_record([b"# Metadata", comment_data.as_slice()]);
718-
writer.serialize(transformed_data.vehicles()).context("failed to write export file")
722+
result.context("failed to write export file")
719723
}
720724
})
721725
{
722726
// fail gracefully
723-
println!("failed to write data export file: {:?}", e);
727+
error!("failed to write data export file: {:?}", e);
724728
}
725729
}
726730
}

src/ui/replay_parser.rs

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use std::sync::mpsc::Sender;
1010
use crate::app::ReplaySettings;
1111
use crate::app::TimedMessage;
1212
use crate::icons;
13+
use crate::replay_export::FlattenedVehicle;
1314
use crate::replay_export::Match;
1415
use crate::task::BackgroundTask;
1516
use crate::task::BackgroundTaskKind;
@@ -168,10 +169,10 @@ fn ship_class_icon_from_species(species: Species, wows_data: &WorldOfWarshipsDat
168169

169170
#[derive(Clone, Serialize)]
170171
pub struct SkillInfo {
171-
skill_points: usize,
172-
num_skills: usize,
173-
highest_tier: usize,
174-
num_tier_1_skills: usize,
172+
pub skill_points: usize,
173+
pub num_skills: usize,
174+
pub highest_tier: usize,
175+
pub num_tier_1_skills: usize,
175176
#[serde(skip)]
176177
hover_text: Option<String>,
177178
#[serde(skip)]
@@ -180,42 +181,42 @@ pub struct SkillInfo {
180181

181182
#[derive(Clone, Serialize)]
182183
pub struct Damage {
183-
ap: Option<u64>,
184-
sap: Option<u64>,
185-
he: Option<u64>,
186-
he_secondaries: Option<u64>,
187-
sap_secondaries: Option<u64>,
188-
torps: Option<u64>,
189-
deep_water_torps: Option<u64>,
190-
fire: Option<u64>,
191-
flooding: Option<u64>,
184+
pub ap: Option<u64>,
185+
pub sap: Option<u64>,
186+
pub he: Option<u64>,
187+
pub he_secondaries: Option<u64>,
188+
pub sap_secondaries: Option<u64>,
189+
pub torps: Option<u64>,
190+
pub deep_water_torps: Option<u64>,
191+
pub fire: Option<u64>,
192+
pub flooding: Option<u64>,
192193
}
193194

194195
#[derive(Clone, Serialize)]
195196
pub struct PotentialDamage {
196-
artillery: u64,
197-
torpedoes: u64,
198-
planes: u64,
197+
pub artillery: u64,
198+
pub torpedoes: u64,
199+
pub planes: u64,
199200
}
200201

201202
#[derive(Clone, Serialize)]
202203
pub struct TranslatedAbility {
203-
name: Option<String>,
204-
game_params_name: String,
204+
pub name: Option<String>,
205+
pub game_params_name: String,
205206
}
206207

207208
#[derive(Clone, Serialize)]
208209
pub struct TranslatedModule {
209-
name: Option<String>,
210-
description: Option<String>,
211-
game_params_name: String,
210+
pub name: Option<String>,
211+
pub description: Option<String>,
212+
pub game_params_name: String,
212213
}
213214

214215
#[derive(Clone, Serialize)]
215216
pub struct TranslatedBuild {
216-
modules: Vec<TranslatedModule>,
217-
abilities: Vec<TranslatedAbility>,
218-
captain_skills: Option<Vec<TranslatedCrewSkill>>,
217+
pub modules: Vec<TranslatedModule>,
218+
pub abilities: Vec<TranslatedAbility>,
219+
pub captain_skills: Option<Vec<TranslatedCrewSkill>>,
219220
}
220221

221222
impl TranslatedBuild {
@@ -265,10 +266,10 @@ impl TranslatedBuild {
265266

266267
#[derive(Clone, Serialize)]
267268
pub struct TranslatedCrewSkill {
268-
tier: usize,
269-
name: Option<String>,
270-
description: Option<String>,
271-
internal_name: String,
269+
pub tier: usize,
270+
pub name: Option<String>,
271+
pub description: Option<String>,
272+
pub internal_name: String,
272273
}
273274

274275
impl TranslatedCrewSkill {
@@ -2213,6 +2214,8 @@ impl ToolkitTabViewer<'_> {
22132214
Some(ReplayExportFormat::Json)
22142215
} else if ui.button("CBOR").clicked() {
22152216
Some(ReplayExportFormat::Cbor)
2217+
} else if ui.button("CSV").clicked() {
2218+
Some(ReplayExportFormat::Csv)
22162219
} else {
22172220
None
22182221
};
@@ -2229,7 +2232,20 @@ impl ToolkitTabViewer<'_> {
22292232
ReplayExportFormat::Cbor => {
22302233
serde_cbor::to_writer(&mut file, &transformed_results).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
22312234
}
2232-
ReplayExportFormat::Csv => todo!("CSV isn't supported yet"),
2235+
ReplayExportFormat::Csv => {
2236+
let mut writer = csv::WriterBuilder::new().has_headers(true).from_writer(file);
2237+
let mut result = Ok(());
2238+
for vehicle in transformed_results.vehicles {
2239+
result = writer.serialize(FlattenedVehicle::from(vehicle));
2240+
if result.is_err() {
2241+
break;
2242+
}
2243+
}
2244+
2245+
let _ = writer.flush();
2246+
2247+
result.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
2248+
}
22332249
};
22342250
if let Err(e) = result {
22352251
error!("Failed to write results to file: {}", e);

0 commit comments

Comments
 (0)