Compare commits

...

No commits in common. "main" and "4d5dbfd25424211d036c6042276e3680669008a1" have entirely different histories.

42 changed files with 4351 additions and 10917 deletions

View File

@ -9,8 +9,8 @@ tasks:
echo 'source $HOME/.nix-profile/etc/profile.d/nix.sh' >> $HOME/.buildenv
- build: |
cd remote-party-finder/server
nix-shell --command 'cargo build --release --target x86_64-unknown-linux-musl'
strip -s target/x86_64-unknown-linux-musl/release/remote-party-finder
# nix-shell --command 'patchelf --remove-rpath --set-interpreter /usr/lib64/ld-linux-x86-64.so.2 target/x86_64-unknown-linux-musl/release/remote-party-finder'
nix-shell --command 'cargo build --release'
strip -s target/release/remote-party-finder
nix-shell --command 'patchelf --remove-rpath --set-interpreter /usr/lib64/ld-linux-x86-64.so.2 target/release/remote-party-finder'
artifacts:
- remote-party-finder/server/target/x86_64-unknown-linux-musl/release/remote-party-finder
- remote-party-finder/server/target/release/remote-party-finder

View File

@ -6,67 +6,68 @@ using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Dalamud.Game;
using Dalamud.Game.Gui.PartyFinder.Types;
using Dalamud.Plugin.Services;
using Dalamud.Logging;
using Newtonsoft.Json;
namespace RemotePartyFinder;
internal class Gatherer : IDisposable {
#if DEBUG
namespace RemotePartyFinder {
internal class Gatherer : IDisposable {
#if DEBUG
private const string UploadUrl = "http://192.168.174.240:7878/contribute/multiple";
#elif RELEASE
private const string UploadUrl = "https://xivpf.com/contribute/multiple";
#endif
#elif RELEASE
private const string UploadUrl = "https://xivpf.com/contribute/multiple";
#endif
private Plugin Plugin { get; }
private Plugin Plugin { get; }
private ConcurrentDictionary<int, List<PartyFinderListing>> Batches { get; } = new();
private Stopwatch UploadTimer { get; } = new();
private HttpClient Client { get; } = new();
private ConcurrentDictionary<int, List<PartyFinderListing>> Batches { get; } = new();
private Stopwatch UploadTimer { get; } = new();
private HttpClient Client { get; } = new();
internal Gatherer(Plugin plugin) {
this.Plugin = plugin;
internal Gatherer(Plugin plugin) {
this.Plugin = plugin;
this.UploadTimer.Start();
this.UploadTimer.Start();
this.Plugin.PartyFinderGui.ReceiveListing += this.OnListing;
this.Plugin.Framework.Update += this.OnUpdate;
}
public void Dispose() {
this.Plugin.Framework.Update -= this.OnUpdate;
this.Plugin.PartyFinderGui.ReceiveListing -= this.OnListing;
}
private void OnListing(PartyFinderListing listing, PartyFinderListingEventArgs args) {
if (!this.Batches.ContainsKey(args.BatchNumber)) {
this.Batches[args.BatchNumber] = new List<PartyFinderListing>();
this.Plugin.PartyFinderGui.ReceiveListing += this.OnListing;
this.Plugin.Framework.Update += this.OnUpdate;
}
this.Batches[args.BatchNumber].Add(listing);
}
private void OnUpdate(IFramework framework1) {
if (this.UploadTimer.Elapsed < TimeSpan.FromSeconds(10)) {
return;
public void Dispose() {
this.Plugin.Framework.Update -= this.OnUpdate;
this.Plugin.PartyFinderGui.ReceiveListing -= this.OnListing;
}
this.UploadTimer.Restart();
private void OnListing(PartyFinderListing listing, PartyFinderListingEventArgs args) {
if (!this.Batches.ContainsKey(args.BatchNumber)) {
this.Batches[args.BatchNumber] = new List<PartyFinderListing>();
}
foreach (var (batch, listings) in this.Batches.ToList()) {
this.Batches.Remove(batch, out _);
Task.Run(async () => {
var uploadable = listings
.Select(listing => new UploadableListing(listing))
.ToList();
var json = JsonConvert.SerializeObject(uploadable);
var resp = await this.Client.PostAsync(UploadUrl, new StringContent(json) {
Headers = { ContentType = MediaTypeHeaderValue.Parse("application/json") },
this.Batches[args.BatchNumber].Add(listing);
}
private void OnUpdate(Framework framework) {
if (this.UploadTimer.Elapsed < TimeSpan.FromSeconds(10)) {
return;
}
this.UploadTimer.Restart();
foreach (var (batch, listings) in this.Batches.ToList()) {
this.Batches.Remove(batch, out _);
Task.Run(async () => {
var uploadable = listings
.Select(listing => new UploadableListing(listing))
.ToList();
var json = JsonConvert.SerializeObject(uploadable);
var resp = await this.Client.PostAsync(UploadUrl, new StringContent(json) {
Headers = { ContentType = MediaTypeHeaderValue.Parse("application/json") },
});
var output = await resp.Content.ReadAsStringAsync();
PluginLog.Log(output);
});
var output = await resp.Content.ReadAsStringAsync();
Plugin.Log.Info(output);
});
}
}
}
}

View File

@ -1,26 +1,26 @@
using Dalamud.IoC;
using Dalamud.Game;
using Dalamud.Game.Gui.PartyFinder;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
namespace RemotePartyFinder;
namespace RemotePartyFinder {
public class Plugin : IDalamudPlugin {
public string Name => "Remote Party Finder";
public class Plugin : IDalamudPlugin {
[PluginService]
internal static IPluginLog Log { get; private set; }
[PluginService]
internal Framework Framework { get; private init; }
[PluginService]
internal IFramework Framework { get; private init; }
[PluginService]
internal PartyFinderGui PartyFinderGui { get; private init; }
[PluginService]
internal IPartyFinderGui PartyFinderGui { get; private init; }
private Gatherer Gatherer { get; }
private Gatherer Gatherer { get; }
public Plugin() {
this.Gatherer = new Gatherer(this);
}
public Plugin() {
this.Gatherer = new Gatherer(this);
}
public void Dispose() {
this.Gatherer.Dispose();
public void Dispose() {
this.Gatherer.Dispose();
}
}
}

View File

@ -1,47 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>1.0.11</Version>
<TargetFramework>net7.0-windows</TargetFramework>
<Version>1.0.3</Version>
<TargetFramework>net5.0-windows</TargetFramework>
<ProduceReferenceAssembly>false</ProduceReferenceAssembly>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup>
<DalamudLibPath>$(AppData)\XIVLauncher\addon\Hooks\dev</DalamudLibPath>
<Dalamud>$(AppData)\XIVLauncher\addon\Hooks\dev</Dalamud>
</PropertyGroup>
<PropertyGroup Condition="'$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))'">
<DalamudLibPath>$(DALAMUD_HOME)</DalamudLibPath>
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
<Dalamud>$(HOME)/games/final-fantasy-xiv-online/drive_c/users/$(USER)/AppData/Roaming/XIVLauncher/addon/Hooks/dev</Dalamud>
</PropertyGroup>
<PropertyGroup Condition="'$(IsCI)' == 'true'">
<DalamudLibPath>$(HOME)/dalamud</DalamudLibPath>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<Dalamud>$(HOME)/dalamud</Dalamud>
</PropertyGroup>
<ItemGroup>
<Reference Include="Dalamud">
<HintPath>$(DalamudLibPath)\Dalamud.dll</HintPath>
<HintPath>$(Dalamud)\Dalamud.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina">
<HintPath>$(DalamudLibPath)\Lumina.dll</HintPath>
<HintPath>$(Dalamud)\Lumina.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Lumina.Excel">
<HintPath>$(DalamudLibPath)\Lumina.Excel.dll</HintPath>
<HintPath>$(Dalamud)\Lumina.Excel.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(DalamudLibPath)\Newtonsoft.Json.dll</HintPath>
<HintPath>$(Dalamud)\Newtonsoft.Json.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="DalamudPackager" Version="2.1.12" />
<PackageReference Include="DalamudLinter" Version="1.0.3"/>
<PackageReference Include="DalamudPackager" Version="2.1.4"/>
</ItemGroup>
</Project>

View File

@ -1,7 +1,7 @@
author: Anna
author: ascclemens
name: Remote Party Finder Uploader
description: |-
Uploads the PF listings you retrieve to the crowdsourced Remote
Party Finder website (https://xivpf.com/).
punchline: Crowdsourced PF website (upload PF listings to xivpf.com)
repo_url: https://git.anna.lgbt/anna/remote-party-finder
repo_url: https://git.sr.ht/~jkcclemens/remote-party-finder

View File

@ -5,68 +5,68 @@ using Dalamud.Game.Gui.PartyFinder.Types;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace RemotePartyFinder;
namespace RemotePartyFinder {
[Serializable]
[JsonObject(NamingStrategyType = typeof(SnakeCaseNamingStrategy))]
internal class UploadableListing {
public uint Id { get; }
public uint ContentIdLower { get; }
public byte[] Name { get; }
public byte[] Description { get; }
public byte CreatedWorld { get; }
public byte HomeWorld { get; }
public byte CurrentWorld { get; }
public DutyCategory Category { get; }
public ushort Duty { get; }
public DutyType DutyType { get; }
public bool BeginnersWelcome { get; }
public ushort SecondsRemaining { get; }
public ushort MinItemLevel { get; }
public byte NumParties { get; }
public byte SlotsAvailable { get; }
public uint LastServerRestart { get; }
public ObjectiveFlags Objective { get; }
public ConditionFlags Conditions { get; }
public DutyFinderSettingsFlags DutyFinderSettings { get; }
public LootRuleFlags LootRules { get; }
public SearchAreaFlags SearchArea { get; }
public List<UploadableSlot> Slots { get; }
public List<byte> JobsPresent { get; }
[Serializable]
[JsonObject(NamingStrategyType = typeof(SnakeCaseNamingStrategy))]
internal class UploadableListing {
public uint Id { get; }
public uint ContentIdLower { get; }
public byte[] Name { get; }
public byte[] Description { get; }
public ushort CreatedWorld { get; }
public ushort HomeWorld { get; }
public ushort CurrentWorld { get; }
public DutyCategory Category { get; }
public ushort Duty { get; }
public DutyType DutyType { get; }
public bool BeginnersWelcome { get; }
public ushort SecondsRemaining { get; }
public ushort MinItemLevel { get; }
public byte NumParties { get; }
public byte SlotsAvailable { get; }
public uint LastServerRestart { get; }
public ObjectiveFlags Objective { get; }
public ConditionFlags Conditions { get; }
public DutyFinderSettingsFlags DutyFinderSettings { get; }
public LootRuleFlags LootRules { get; }
public SearchAreaFlags SearchArea { get; }
public List<UploadableSlot> Slots { get; }
public List<byte> JobsPresent { get; }
internal UploadableListing(PartyFinderListing listing) {
this.Id = listing.Id;
this.ContentIdLower = listing.ContentIdLower;
this.Name = listing.Name.Encode();
this.Description = listing.Description.Encode();
this.CreatedWorld = (byte) listing.World.Value.RowId;
this.HomeWorld = (byte) listing.HomeWorld.Value.RowId;
this.CurrentWorld = (byte) listing.CurrentWorld.Value.RowId;
this.Category = listing.Category;
this.Duty = listing.RawDuty;
this.DutyType = listing.DutyType;
this.BeginnersWelcome = listing.BeginnersWelcome;
this.SecondsRemaining = listing.SecondsRemaining;
this.MinItemLevel = listing.MinimumItemLevel;
this.NumParties = listing.Parties;
this.SlotsAvailable = listing.SlotsAvailable;
this.LastServerRestart = listing.LastPatchHotfixTimestamp;
this.Objective = listing.Objective;
this.Conditions = listing.Conditions;
this.DutyFinderSettings = listing.DutyFinderSettings;
this.LootRules = listing.LootRules;
this.SearchArea = listing.SearchArea;
this.Slots = listing.Slots.Select(slot => new UploadableSlot(slot)).ToList();
this.JobsPresent = listing.RawJobsPresent.ToList();
}
}
internal UploadableListing(PartyFinderListing listing) {
this.Id = listing.Id;
this.ContentIdLower = listing.ContentIdLower;
this.Name = listing.Name.Encode();
this.Description = listing.Description.Encode();
this.CreatedWorld = (ushort) listing.World.Value.RowId;
this.HomeWorld = (ushort) listing.HomeWorld.Value.RowId;
this.CurrentWorld = (ushort) listing.CurrentWorld.Value.RowId;
this.Category = listing.Category;
this.Duty = listing.RawDuty;
this.DutyType = listing.DutyType;
this.BeginnersWelcome = listing.BeginnersWelcome;
this.SecondsRemaining = listing.SecondsRemaining;
this.MinItemLevel = listing.MinimumItemLevel;
this.NumParties = listing.Parties;
this.SlotsAvailable = listing.SlotsAvailable;
this.LastServerRestart = listing.LastPatchHotfixTimestamp;
this.Objective = listing.Objective;
this.Conditions = listing.Conditions;
this.DutyFinderSettings = listing.DutyFinderSettings;
this.LootRules = listing.LootRules;
this.SearchArea = listing.SearchArea;
this.Slots = listing.Slots.Select(slot => new UploadableSlot(slot)).ToList();
this.JobsPresent = listing.RawJobsPresent.ToList();
[Serializable]
[JsonObject(NamingStrategyType = typeof(SnakeCaseNamingStrategy))]
internal class UploadableSlot {
public JobFlags Accepting { get; }
internal UploadableSlot(PartyFinderSlot slot) {
this.Accepting = slot.Accepting.Aggregate((JobFlags) 0, ((agg, flag) => agg | flag));
}
}
}
[Serializable]
[JsonObject(NamingStrategyType = typeof(SnakeCaseNamingStrategy))]
internal class UploadableSlot {
public JobFlags Accepting { get; }
internal UploadableSlot(PartyFinderSlot slot) {
this.Accepting = slot.Accepting.Aggregate((JobFlags) 0, (agg, flag) => agg | flag);
}
}

View File

@ -1,13 +0,0 @@
{
"version": 1,
"dependencies": {
"net7.0-windows7.0": {
"DalamudPackager": {
"type": "Direct",
"requested": "[2.1.12, )",
"resolved": "2.1.12",
"contentHash": "Sc0PVxvgg4NQjcI8n10/VfUQBAS4O+Fw2pZrAqBdRMbthYGeogzu5+xmIGCGmsEZ/ukMOBuAqiNiB5qA3MRalg=="
}
}
}
}

View File

@ -3,83 +3,83 @@ using Pidgin;
using static Pidgin.Parser;
using static Pidgin.Parser<char>;
namespace SourceGenerator;
namespace SourceGenerator {
internal static class AutoTranslate {
internal static Parser<char, (string name, Maybe<IEnumerable<ISelectorPart>> selector)> Parser() {
var sheetName = Any
.AtLeastOnceUntil(Lookahead(Char('[').IgnoreResult().Or(End)))
.Select(string.Concat)
.Labelled("sheetName");
internal static class AutoTranslate {
internal static Parser<char, (string name, Maybe<IEnumerable<ISelectorPart>> selector)> Parser() {
var sheetName = Any
.AtLeastOnceUntil(Lookahead(Char('[').IgnoreResult().Or(End)))
.Select(string.Concat)
.Labelled("sheetName");
var numPair = Map(
(first, second) => (ISelectorPart) new IndexRange(
uint.Parse(string.Concat(first)),
uint.Parse(string.Concat(second))
),
Digit.AtLeastOnce().Before(Char('-')),
Digit.AtLeastOnce()
)
.Labelled("numPair");
var singleRow = Digit
.AtLeastOnce()
.Select(string.Concat)
.Select(num => (ISelectorPart) new SingleRow(uint.Parse(num)));
var column = String("col-")
.Then(Digit.AtLeastOnce())
.Select(string.Concat)
.Select(num => (ISelectorPart) new ColumnSpecifier(uint.Parse(num)));
var noun = String("noun")
.Select(_ => (ISelectorPart) new NounMarker());
var numPair = Map(
(first, second) => (ISelectorPart) new IndexRange(
uint.Parse(string.Concat(first)),
uint.Parse(string.Concat(second))
),
Digit.AtLeastOnce().Before(Char('-')),
Digit.AtLeastOnce()
)
.Labelled("numPair");
var singleRow = Digit
.AtLeastOnce()
.Select(string.Concat)
.Select(num => (ISelectorPart) new SingleRow(uint.Parse(num)));
var column = String("col-")
.Then(Digit.AtLeastOnce())
.Select(string.Concat)
.Select(num => (ISelectorPart) new ColumnSpecifier(uint.Parse(num)));
var noun = String("noun")
.Select(_ => (ISelectorPart) new NounMarker());
var selectorItems = OneOf(
Try(numPair),
singleRow,
column,
noun
)
.Separated(Char(','))
.Labelled("selectorItems");
var selector = selectorItems
.Between(Char('['), Char(']'))
.Labelled("selector");
var selectorItems = OneOf(
Try(numPair),
singleRow,
column,
noun
)
.Separated(Char(','))
.Labelled("selectorItems");
var selector = selectorItems
.Between(Char('['), Char(']'))
.Labelled("selector");
return Map(
(name, selector) => (name, selector),
sheetName,
selector.Optional()
);
}
}
return Map(
(name, selector) => (name, selector),
sheetName,
selector.Optional()
);
internal interface ISelectorPart {
}
internal class SingleRow : ISelectorPart {
public uint Row { get; }
public SingleRow(uint row) {
this.Row = row;
}
}
internal class IndexRange : ISelectorPart {
public uint Start { get; }
public uint End { get; }
public IndexRange(uint start, uint end) {
this.Start = start;
this.End = end;
}
}
internal class NounMarker : ISelectorPart {
}
internal class ColumnSpecifier : ISelectorPart {
public uint Column { get; }
public ColumnSpecifier(uint column) {
this.Column = column;
}
}
}
internal interface ISelectorPart {
}
internal class SingleRow : ISelectorPart {
public uint Row { get; }
public SingleRow(uint row) {
this.Row = row;
}
}
internal class IndexRange : ISelectorPart {
public uint Start { get; }
public uint End { get; }
public IndexRange(uint start, uint end) {
this.Start = start;
this.End = end;
}
}
internal class NounMarker : ISelectorPart {
}
internal class ColumnSpecifier : ISelectorPart {
public uint Column { get; }
public ColumnSpecifier(uint column) {
this.Column = column;
}
}

View File

@ -10,452 +10,452 @@ using Lumina.Excel.GeneratedSheets;
using Lumina.Text;
using Pidgin;
namespace SourceGenerator;
internal class Program {
private static void Main(string[] args) {
var data = new Dictionary<Language, GameData>(4);
foreach (var lang in Languages.Keys) {
data[lang] = new GameData(args[0], new LuminaOptions {
PanicOnSheetChecksumMismatch = false,
DefaultExcelLanguage = lang,
});
}
var prog = new Program(data);
File.WriteAllText(Path.Join(args[1], "duties.rs"), prog.GenerateDuties());
File.WriteAllText(Path.Join(args[1], "jobs.rs"), prog.GenerateJobs());
File.WriteAllText(Path.Join(args[1], "roulettes.rs"), prog.GenerateRoulettes());
File.WriteAllText(Path.Join(args[1], "worlds.rs"), prog.GenerateWorlds());
File.WriteAllText(Path.Join(args[1], "territory_names.rs"), prog.GenerateTerritoryNames());
File.WriteAllText(Path.Join(args[1], "auto_translate.rs"), prog.GenerateAutoTranslate());
File.WriteAllText(Path.Join(args[1], "treasure_maps.rs"), prog.GenerateTreasureMaps());
}
private Dictionary<Language, GameData> Data { get; }
private Program(Dictionary<Language, GameData> data) {
this.Data = data;
}
private static StringBuilder DefaultHeader(bool localisedText = false) {
var sb = new StringBuilder("use std::collections::HashMap;\n");
if (localisedText) {
sb.Append("use super::LocalisedText;\n");
}
return sb;
}
private static readonly Dictionary<Language, string> Languages = new() {
[Language.English] = "en",
[Language.Japanese] = "ja",
[Language.German] = "de",
[Language.French] = "fr",
};
private string? GetLocalisedStruct<T>(uint rowId, Func<T, SeString?> nameFunc, uint indent = 0, bool capitalise = false) where T : ExcelRow {
var def = this.Data[Language.English].GetExcelSheet<T>()!.GetRow(rowId)!;
var defName = nameFunc(def)?.TextValue();
if (string.IsNullOrEmpty(defName)) {
return null;
}
var sb = new StringBuilder();
sb.Append("LocalisedText {\n");
foreach (var (language, key) in Languages) {
var row = this.Data[language].GetExcelSheet<T>(language)?.GetRow(rowId);
var name = row == null
? defName
: nameFunc(row)?.TextValue().Replace("\"", "\\\"");
name ??= defName;
if (capitalise) {
name = name[..1].ToUpperInvariant() + name[1..];
namespace SourceGenerator {
internal class Program {
private static void Main(string[] args) {
var data = new Dictionary<Language, GameData>(4);
foreach (var lang in Languages.Keys) {
data[lang] = new GameData(args[0], new LuminaOptions {
DefaultExcelLanguage = lang,
});
}
for (var i = 0; i < indent + 4; i++) {
var prog = new Program(data);
File.WriteAllText(Path.Join(args[1], "duties.rs"), prog.GenerateDuties());
File.WriteAllText(Path.Join(args[1], "jobs.rs"), prog.GenerateJobs());
File.WriteAllText(Path.Join(args[1], "roulettes.rs"), prog.GenerateRoulettes());
File.WriteAllText(Path.Join(args[1], "worlds.rs"), prog.GenerateWorlds());
File.WriteAllText(Path.Join(args[1], "territory_names.rs"), prog.GenerateTerritoryNames());
File.WriteAllText(Path.Join(args[1], "auto_translate.rs"), prog.GenerateAutoTranslate());
File.WriteAllText(Path.Join(args[1], "treasure_maps.rs"), prog.GenerateTreasureMaps());
}
private Dictionary<Language, GameData> Data { get; }
private Program(Dictionary<Language, GameData> data) {
this.Data = data;
}
private static StringBuilder DefaultHeader(bool localisedText = false) {
var sb = new StringBuilder("use std::collections::HashMap;\n");
if (localisedText) {
sb.Append("use super::LocalisedText;\n");
}
return sb;
}
private static readonly Dictionary<Language, string> Languages = new() {
[Language.English] = "en",
[Language.Japanese] = "ja",
[Language.German] = "de",
[Language.French] = "fr",
};
private string? GetLocalisedStruct<T>(uint rowId, Func<T, SeString?> nameFunc, uint indent = 0, bool capitalise = false) where T : ExcelRow {
var def = this.Data[Language.English].GetExcelSheet<T>()!.GetRow(rowId)!;
var defName = nameFunc(def)?.TextValue();
if (string.IsNullOrEmpty(defName)) {
return null;
}
var sb = new StringBuilder();
sb.Append("LocalisedText {\n");
foreach (var (language, key) in Languages) {
var row = this.Data[language].GetExcelSheet<T>(language)?.GetRow(rowId);
var name = row == null
? defName
: nameFunc(row)?.TextValue().Replace("\"", "\\\"");
name ??= defName;
if (capitalise) {
name = name[..1].ToUpperInvariant() + name[1..];
}
for (var i = 0; i < indent + 4; i++) {
sb.Append(' ');
}
sb.Append($"{key}: \"{name}\",\n");
}
for (var i = 0; i < indent; i++) {
sb.Append(' ');
}
sb.Append($"{key}: \"{name}\",\n");
sb.Append('}');
return sb.ToString();
}
for (var i = 0; i < indent; i++) {
sb.Append(' ');
private string GenerateDuties() {
var sb = DefaultHeader(true);
sb.Append('\n');
sb.Append("#[derive(Debug)]\n");
sb.Append("pub struct DutyInfo {\n");
sb.Append(" pub name: LocalisedText,\n");
sb.Append(" pub high_end: bool,\n");
sb.Append(" pub content_kind: ContentKind,\n");
sb.Append("}\n\n");
sb.Append("#[derive(Debug, Clone, Copy)]\n");
sb.Append("#[allow(unused)]\n");
sb.Append("#[repr(u32)]\n");
sb.Append("pub enum ContentKind {\n");
foreach (var kind in this.Data[Language.English].GetExcelSheet<ContentType>()!) {
var name = kind.Name.TextValue().Replace(" ", "");
if (name.Length > 0) {
sb.Append($" {name} = {kind.RowId},\n");
}
}
sb.Append(" Other(u32),\n");
sb.Append("}\n\n");
sb.Append("impl ContentKind {\n");
sb.Append(" fn from_u32(kind: u32) -> Self {\n");
sb.Append(" match kind {\n");
foreach (var kind in this.Data[Language.English].GetExcelSheet<ContentType>()!) {
var name = kind.Name.TextValue().Replace(" ", "");
if (name.Length > 0) {
sb.Append($" {kind.RowId} => Self::{name},\n");
}
}
sb.Append(" x => Self::Other(x),\n");
sb.Append(" }\n");
sb.Append(" }\n\n");
sb.Append(" pub fn as_u32(self) -> u32 {\n");
sb.Append(" match self {\n");
foreach (var kind in this.Data[Language.English].GetExcelSheet<ContentType>()!) {
var name = kind.Name.TextValue().Replace(" ", "");
if (name.Length > 0) {
sb.Append($" Self::{name} => {kind.RowId},\n");
}
}
sb.Append(" Self::Other(x) => x,\n");
sb.Append(" }\n");
sb.Append(" }\n");
sb.Append("}\n\n");
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref DUTIES: HashMap<u32, DutyInfo> = maplit::hashmap! {\n");
foreach (var cfc in this.Data[Language.English].GetExcelSheet<ContentFinderCondition>()!) {
if (cfc.RowId == 0) {
continue;
}
var name = this.GetLocalisedStruct<ContentFinderCondition>(cfc.RowId, row => row.Name, 12, true);
if (name == null) {
continue;
}
var highEnd = cfc.HighEndDuty ? "true" : "false";
var contentType = cfc.ContentType.Value;
var contentKind = contentType?.Name?.TextValue().Replace(" ", "");
if (string.IsNullOrEmpty(contentKind)) {
contentKind = $"Other({contentType?.RowId ?? 0})";
}
sb.Append($" {cfc.RowId} => DutyInfo {{\n");
sb.Append($" name: {name},\n");
sb.Append($" high_end: {highEnd},\n");
sb.Append($" content_kind: ContentKind::{contentKind},\n");
sb.Append(" },\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
sb.Append('}');
private string GenerateJobs() {
var sb = DefaultHeader();
sb.Append("use ffxiv_types::jobs::{ClassJob, Class, Job, NonCombatJob};\n\n");
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref JOBS: HashMap<u32, ClassJob> = maplit::hashmap! {\n");
return sb.ToString();
}
foreach (var cj in this.Data[Language.English].GetExcelSheet<ClassJob>()!) {
if (cj.RowId == 0) {
continue;
}
private string GenerateDuties() {
var sb = DefaultHeader(true);
sb.Append('\n');
var name = cj.NameEnglish.TextValue().Replace(" ", "");
if (name.Length <= 0) {
continue;
}
sb.Append("#[derive(Debug)]\n");
sb.Append("pub struct DutyInfo {\n");
sb.Append(" pub name: LocalisedText,\n");
sb.Append(" pub high_end: bool,\n");
sb.Append(" pub content_kind: ContentKind,\n");
sb.Append("}\n\n");
var isCombat = cj.Role != 0;
var isClass = cj.JobIndex == 0;
sb.Append("#[derive(Debug, Clone, Copy)]\n");
sb.Append("#[allow(unused)]\n");
sb.Append("#[repr(u32)]\n");
sb.Append("pub enum ContentKind {\n");
foreach (var kind in this.Data[Language.English].GetExcelSheet<ContentType>()!) {
var name = kind.Name.TextValue().Replace(" ", "").Replace("&", "");
if (name.Length > 0) {
sb.Append($" {name} = {kind.RowId},\n");
string value;
if (isCombat) {
value = isClass
? $"ClassJob::Class(Class::{name})"
: $"ClassJob::Job(Job::{name})";
} else {
value = $"ClassJob::NonCombat(NonCombatJob::{name})";
}
sb.Append($" {cj.RowId} => {value},\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
sb.Append(" Other(u32),\n");
sb.Append("}\n\n");
private string GenerateRoulettes() {
var sb = DefaultHeader(true);
sb.Append('\n');
sb.Append("#[derive(Debug)]\n");
sb.Append("pub struct RouletteInfo {\n");
sb.Append(" pub name: LocalisedText,\n");
sb.Append(" pub pvp: bool,\n");
sb.Append("}\n\n");
sb.Append("impl ContentKind {\n");
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref ROULETTES: HashMap<u32, RouletteInfo> = maplit::hashmap! {\n");
sb.Append(" fn from_u32(kind: u32) -> Self {\n");
sb.Append(" match kind {\n");
foreach (var kind in this.Data[Language.English].GetExcelSheet<ContentType>()!) {
var name = kind.Name.TextValue().Replace(" ", "").Replace("&", "");
if (name.Length > 0) {
sb.Append($" {kind.RowId} => Self::{name},\n");
foreach (var cr in this.Data[Language.English].GetExcelSheet<ContentRoulette>()!) {
if (cr.RowId == 0) {
continue;
}
var name = this.GetLocalisedStruct<ContentRoulette>(cr.RowId, row => row.Name, 12);
if (name == null) {
continue;
}
var pvp = cr.Unknown28 == 6
? "true"
: "false";
sb.Append($" {cr.RowId} => RouletteInfo {{\n");
sb.Append($" name: {name},\n");
sb.Append($" pvp: {pvp},\n");
sb.Append(" },\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
sb.Append(" x => Self::Other(x),\n");
sb.Append(" }\n");
sb.Append(" }\n\n");
private string GenerateWorlds() {
var sb = DefaultHeader();
sb.Append("use ffxiv_types::World;\n\n");
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref WORLDS: HashMap<u32, World> = maplit::hashmap! {\n");
sb.Append(" pub fn as_u32(self) -> u32 {\n");
sb.Append(" match self {\n");
foreach (var kind in this.Data[Language.English].GetExcelSheet<ContentType>()!) {
var name = kind.Name.TextValue().Replace(" ", "").Replace("&", "");
if (name.Length > 0) {
sb.Append($" Self::{name} => {kind.RowId},\n");
foreach (var world in this.Data[Language.English].GetExcelSheet<World>()!) {
if (world.RowId == 0 || !world.IsPublic || world.DataCenter.Row == 0) {
continue;
}
var name = world.Name.TextValue();
if (name.Length <= 0) {
continue;
}
sb.Append($" {world.RowId} => World::{name},\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
sb.Append(" Self::Other(x) => x,\n");
sb.Append(" }\n");
sb.Append(" }\n");
private string GenerateTerritoryNames() {
var sb = DefaultHeader(true);
sb.Append("\nlazy_static::lazy_static! {\n");
sb.Append(" pub static ref TERRITORY_NAMES: HashMap<u32, LocalisedText> = maplit::hashmap! {\n");
sb.Append("}\n\n");
foreach (var tt in this.Data[Language.English].GetExcelSheet<TerritoryType>()!) {
if (tt.RowId == 0 || tt.PlaceName.Row == 0) {
continue;
}
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref DUTIES: HashMap<u32, DutyInfo> = maplit::hashmap! {\n");
var name = this.GetLocalisedStruct<TerritoryType>(
tt.RowId,
row => row.PlaceName.Value!.Name,
8
);
if (name == null) {
continue;
}
foreach (var cfc in this.Data[Language.English].GetExcelSheet<ContentFinderCondition>()!) {
if (cfc.RowId == 0) {
continue;
sb.Append($" {tt.RowId} => {name},\n");
}
var name = this.GetLocalisedStruct<ContentFinderCondition>(cfc.RowId, row => row.Name, 12, true);
if (name == null) {
continue;
}
sb.Append(" };\n");
sb.Append("}\n");
var highEnd = cfc.HighEndDuty ? "true" : "false";
var contentType = cfc.ContentType.Value;
var contentKind = contentType?.Name?.TextValue().Replace(" ", "").Replace("&", "");
if (string.IsNullOrEmpty(contentKind)) {
contentKind = $"Other({contentType?.RowId ?? 0})";
}
sb.Append($" {cfc.RowId} => DutyInfo {{\n");
sb.Append($" name: {name},\n");
sb.Append($" high_end: {highEnd},\n");
sb.Append($" content_kind: ContentKind::{contentKind},\n");
sb.Append(" },\n");
return sb.ToString();
}
sb.Append(" };\n");
sb.Append("}\n");
private string GenerateAutoTranslate() {
var sb = DefaultHeader(true);
sb.Append("\nlazy_static::lazy_static! {\n");
sb.Append(" pub static ref AUTO_TRANSLATE: HashMap<(u32, u32), LocalisedText> = maplit::hashmap! {\n");
return sb.ToString();
}
var parser = AutoTranslate.Parser();
foreach (var row in this.Data[Language.English].GetExcelSheet<Completion>()!) {
var lookup = row.LookupTable.TextValue();
if (lookup is not ("" or "@")) {
var (sheetName, selector) = parser.ParseOrThrow(lookup);
var sheetType = typeof(Completion)
.Assembly
.GetType($"Lumina.Excel.GeneratedSheets.{sheetName}")!;
var getSheet = this.Data[Language.English]
.GetType()
.GetMethod("GetExcelSheet", Type.EmptyTypes)!
.MakeGenericMethod(sheetType);
var sheets = this.Data.ToDictionary(
pair => pair.Key,
pair => {
var sheet = (ExcelSheetImpl) getSheet.Invoke(pair.Value, null)!;
return (sheet, sheet.EnumerateRowParsers().ToArray());
});
private string GenerateJobs() {
var sb = DefaultHeader();
sb.Append("use ffxiv_types::jobs::{ClassJob, Class, Job, NonCombatJob};\n\n");
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref JOBS: HashMap<u32, ClassJob> = maplit::hashmap! {\n");
var columns = new List<int>();
var rows = new List<Range>();
if (selector.HasValue) {
columns.Clear();
rows.Clear();
foreach (var cj in this.Data[Language.English].GetExcelSheet<ClassJob>()!) {
if (cj.RowId == 0) {
continue;
}
var name = cj.NameEnglish.TextValue().Replace(" ", "");
if (name.Length <= 0) {
continue;
}
var isCombat = cj.Role != 0;
var isClass = cj.JobIndex == 0;
string value;
if (isCombat) {
value = isClass
? $"ClassJob::Class(Class::{name})"
: $"ClassJob::Job(Job::{name})";
} else {
value = $"ClassJob::NonCombat(NonCombatJob::{name})";
}
sb.Append($" {cj.RowId} => {value},\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
private string GenerateRoulettes() {
var sb = DefaultHeader(true);
sb.Append('\n');
sb.Append("#[derive(Debug)]\n");
sb.Append("pub struct RouletteInfo {\n");
sb.Append(" pub name: LocalisedText,\n");
sb.Append(" pub pvp: bool,\n");
sb.Append("}\n\n");
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref ROULETTES: HashMap<u32, RouletteInfo> = maplit::hashmap! {\n");
foreach (var cr in this.Data[Language.English].GetExcelSheet<ContentRoulette>()!) {
if (cr.RowId == 0) {
continue;
}
var name = this.GetLocalisedStruct<ContentRoulette>(cr.RowId, row => row.Name, 12);
if (name == null) {
continue;
}
var pvp = cr.IsPvP
? "true"
: "false";
sb.Append($" {cr.RowId} => RouletteInfo {{\n");
sb.Append($" name: {name},\n");
sb.Append($" pvp: {pvp},\n");
sb.Append(" },\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
private string GenerateWorlds() {
var sb = DefaultHeader();
sb.Append("use ffxiv_types::World;\n\n");
sb.Append("lazy_static::lazy_static! {\n");
sb.Append(" pub static ref WORLDS: HashMap<u32, World> = maplit::hashmap! {\n");
foreach (var world in this.Data[Language.English].GetExcelSheet<World>()!) {
if (world.RowId == 0 || !world.IsPublic || world.UserType == 0 || world.DataCenter.Row == 0) {
continue;
}
var name = world.Name.TextValue();
if (name.Length <= 0) {
continue;
}
sb.Append($" {world.RowId} => World::{name},\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
private string GenerateTerritoryNames() {
var sb = DefaultHeader(true);
sb.Append("\nlazy_static::lazy_static! {\n");
sb.Append(" pub static ref TERRITORY_NAMES: HashMap<u32, LocalisedText> = maplit::hashmap! {\n");
foreach (var tt in this.Data[Language.English].GetExcelSheet<TerritoryType>()!) {
if (tt.RowId == 0 || tt.PlaceName.Row == 0) {
continue;
}
var name = this.GetLocalisedStruct<TerritoryType>(
tt.RowId,
row => row.PlaceName.Value!.Name,
8
);
if (name == null) {
continue;
}
sb.Append($" {tt.RowId} => {name},\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
private string GenerateAutoTranslate() {
var sb = DefaultHeader(true);
sb.Append("\nlazy_static::lazy_static! {\n");
sb.Append(" pub static ref AUTO_TRANSLATE: HashMap<(u32, u32), LocalisedText> = maplit::hashmap! {\n");
var parser = AutoTranslate.Parser();
foreach (var row in this.Data[Language.English].GetExcelSheet<Completion>()!) {
var lookup = row.LookupTable.TextValue();
if (lookup is not ("" or "@")) {
var (sheetName, selector) = parser.ParseOrThrow(lookup);
var sheetType = typeof(Completion)
.Assembly
.GetType($"Lumina.Excel.GeneratedSheets.{sheetName}")!;
var getSheet = this.Data[Language.English]
.GetType()
.GetMethod("GetExcelSheet", Type.EmptyTypes)!
.MakeGenericMethod(sheetType);
var sheets = this.Data.ToDictionary(
pair => pair.Key,
pair => {
var sheet = (ExcelSheetImpl) getSheet.Invoke(pair.Value, null)!;
return (sheet, sheet.GetRowParsers().ToArray());
});
var columns = new List<int>();
var rows = new List<Range>();
if (selector.HasValue) {
columns.Clear();
rows.Clear();
foreach (var part in selector.Value) {
switch (part) {
case IndexRange range: {
var start = (int) range.Start;
var end = (int) (range.End + 1);
rows.Add(start..end);
break;
foreach (var part in selector.Value) {
switch (part) {
case IndexRange range: {
var start = (int) range.Start;
var end = (int) (range.End + 1);
rows.Add(start..end);
break;
}
case SingleRow single: {
var idx = (int) single.Row;
rows.Add(idx..(idx + 1));
break;
}
case ColumnSpecifier col:
columns.Add((int) col.Column);
break;
}
case SingleRow single: {
var idx = (int) single.Row;
rows.Add(idx..(idx + 1));
break;
}
case ColumnSpecifier col:
columns.Add((int) col.Column);
break;
}
}
}
if (columns.Count == 0) {
columns.Add(0);
}
if (columns.Count == 0) {
columns.Add(0);
}
if (rows.Count == 0) {
rows.Add(..);
}
if (rows.Count == 0) {
rows.Add(..);
}
var builder = new StringBuilder();
foreach (var range in rows) {
var validRows = sheets[Language.English]
.Item2
.Select(parser => parser.RowId)
.ToArray();
for (var i = range.Start.Value; i < range.End.Value; i++) {
if (!validRows.Contains((uint) i)) {
continue;
}
var builder = new StringBuilder();
foreach (var range in rows) {
var validRows = sheets[Language.English]
.Item2
.Select(parser => parser.Row)
.ToArray();
for (var i = range.Start.Value; i < range.End.Value; i++) {
if (!validRows.Contains((uint) i)) {
continue;
}
builder.Clear();
builder.Clear();
builder.Append($" ({row.Group}, {i}) => LocalisedText {{\n");
builder.Append($" ({row.Group}, {i}) => LocalisedText {{\n");
var lines = 0;
foreach (var (lang, (_, parsers)) in sheets) {
// take the first column that works
foreach (var col in columns) {
var rowParser = parsers.FirstOrDefault(parser => parser.RowId == i);
if (rowParser != null) {
var name = rowParser.ReadColumn<SeString>(col)!;
var text = name.TextValue().Replace("\"", "\\\"");
if (text.Length > 0) {
builder.Append($" {Languages[lang]}: \"{text}\",\n");
lines += 1;
break;
}
var lines = 0;
foreach (var (lang, (_, parsers)) in sheets) {
// take the first column that works
foreach (var col in columns) {
var rowParser = parsers.FirstOrDefault(parser => parser.Row == i);
if (rowParser != null) {
var name = rowParser.ReadColumn<SeString>(col)!;
var text = name.TextValue().Replace("\"", "\\\"");
if (text.Length > 0) {
builder.Append($" {Languages[lang]}: \"{text}\",\n");
lines += 1;
break;
}
}
}
}
builder.Append(" },\n");
if (lines != 4) {
continue;
}
sb.Append(builder);
}
builder.Append(" },\n");
if (lines != 4) {
continue;
}
sb.Append(builder);
}
// TODO: do lookup
} else {
var text = this.GetLocalisedStruct<Completion>(row.RowId, row => row.Text, 8);
if (text != null) {
sb.Append($" ({row.Group}, {row.RowId}) => {text},\n");
}
}
} else {
var text = this.GetLocalisedStruct<Completion>(row.RowId, row => row.Text, 8);
if (text != null) {
sb.Append($" ({row.Group}, {row.RowId}) => {text},\n");
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
private string GenerateTreasureMaps() {
var sb = DefaultHeader(true);
sb.Append("\nlazy_static::lazy_static! {\n");
sb.Append(" pub static ref TREASURE_MAPS: HashMap<u32, LocalisedText> = maplit::hashmap! {\n");
sb.Append(" 0 => LocalisedText {\n");
sb.Append(" en: \"All Levels\",\n");
sb.Append(" ja: \"レベルを指定しない\",\n");
sb.Append(" de: \"Jede Stufe\",\n");
sb.Append(" fr: \"Tous niveaux\",\n");
sb.Append(" },\n");
var i = 1;
foreach (var row in this.Data[Language.English].GetExcelSheet<TreasureHuntRank>()!) {
// IS THIS RIGHT?
if (row.TreasureHuntTexture != 0) {
continue;
}
SeString? GetMapName(TreasureHuntRank thr) {
var name = thr.KeyItemName.Value?.Name;
return string.IsNullOrEmpty(name?.TextValue())
? thr.ItemName.Value?.Name
: name;
}
var name = this.GetLocalisedStruct<TreasureHuntRank>(row.RowId, GetMapName, 8);
if (!string.IsNullOrEmpty(name)) {
sb.Append($" {i++} => {name},\n");
}
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
private string GenerateTreasureMaps() {
var sb = DefaultHeader(true);
sb.Append("\nlazy_static::lazy_static! {\n");
sb.Append(" pub static ref TREASURE_MAPS: HashMap<u32, LocalisedText> = maplit::hashmap! {\n");
sb.Append(" 0 => LocalisedText {\n");
sb.Append(" en: \"All Levels\",\n");
sb.Append(" ja: \"レベルを指定しない\",\n");
sb.Append(" de: \"Jede Stufe\",\n");
sb.Append(" fr: \"Tous niveaux\",\n");
sb.Append(" },\n");
var i = 1;
foreach (var row in this.Data[Language.English].GetExcelSheet<TreasureHuntRank>()!) {
// IS THIS RIGHT?
if (row.TreasureHuntTexture != 0) {
continue;
}
SeString? GetMapName(TreasureHuntRank thr) {
var name = thr.KeyItemName.Value?.Name;
return string.IsNullOrEmpty(name?.TextValue())
? thr.ItemName.Value?.Name
: name;
}
var name = this.GetLocalisedStruct<TreasureHuntRank>(row.RowId, GetMapName, 8);
if (!string.IsNullOrEmpty(name)) {
sb.Append($" {i++} => {name},\n");
}
}
sb.Append(" };\n");
sb.Append("}\n");
return sb.ToString();
}
}
}

View File

@ -2,36 +2,36 @@
using Lumina.Text;
using Lumina.Text.Payloads;
namespace SourceGenerator;
namespace SourceGenerator {
internal static class SeStringExtensions {
internal static string TextValue(this SeString str) {
var payloads = str.Payloads
.Select(p => {
if (p is TextPayload text) {
return p.Data[0] == 0x03
? text.RawString[1..]
: text.RawString;
}
internal static class SeStringExtensions {
internal static string TextValue(this SeString str) {
var payloads = str.Payloads
.Select(p => {
if (p is TextPayload text) {
return p.Data[0] == 0x03
? text.RawString[1..]
: text.RawString;
}
if (p.Data.Length <= 1) {
return "";
}
if (p.Data[1] == 0x1F) {
return "-";
}
if (p.Data.Length > 2 && p.Data[1] == 0x20) {
var value = p.Data.Length > 4
? p.Data[3] - 1
: p.Data[2];
return ((char) (48 + value)).ToString();
}
if (p.Data.Length <= 1) {
return "";
}
});
if (p.Data[1] == 0x1F) {
return "-";
}
if (p.Data.Length > 2 && p.Data[1] == 0x20) {
var value = p.Data.Length > 4
? p.Data[3] - 1
: p.Data[2];
return ((char) (48 + value)).ToString();
}
return "";
});
return string.Join("", payloads);
return string.Join("", payloads);
}
}
}
}

View File

@ -2,14 +2,14 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Lumina" Version="3.10.0"/>
<PackageReference Include="Lumina.Excel" Version="6.3.2"/>
<PackageReference Include="Pidgin" Version="3.2.1"/>
<PackageReference Include="Lumina" Version="3.4.1"/>
<PackageReference Include="Lumina.Excel" Version="5.50.0"/>
<PackageReference Include="Pidgin" Version="3.0.0"/>
</ItemGroup>
</Project>

View File

@ -1,2 +1,2 @@
#use nix
#unset RUSTC_WRAPPER
use nix
unset RUSTC_WRAPPER

1207
server/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
[package]
name = "remote-party-finder"
version = "0.1.0"
edition = "2021"
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
askama = { version = "0.11", features = ["with-warp"] }
askama_warp = "0.12"
askama = { version = "0.10", features = ["with-warp"] }
askama_warp = "0.11"
base64 = "0.13"
bitflags = "1"
chrono = { version = "0.4", features = ["serde"] }
@ -18,13 +18,13 @@ lazy_static = "1"
maplit = "1"
mime = "0.3"
mongodb = { version = "2", features = ["bson-chrono-0_4"] }
sestring = { version = "0.3", features = ["serde"] }
sestring = { version = "0.1", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_repr = "0.1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
tokio-stream = "0.1"
toml = "0.7"
toml = "0.5"
warp = { version = "0.3", default-features = false }
[dev-dependencies]

View File

@ -33,8 +33,8 @@ body {
margin: 0;
font-family: sans-serif;
/* background-color: var(--background); */
/* color: var(--text); */
background-color: var(--background);
color: var(--text);
}
.js body {

View File

@ -1,13 +0,0 @@
(function () {
function setUpLanguage() {
let language = document.getElementById('language');
for (let elem of language.querySelectorAll('[data-value]')) {
elem.addEventListener('click', () => {
document.cookie = `lang=${encodeURIComponent(elem.dataset.value)};path=/;max-age=31536000;samesite=lax`;
window.location.reload();
});
}
}
setUpLanguage();
})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

View File

@ -28,15 +28,11 @@
margin-top: 1em;
}
#container > .settings > .controls {
#container > .settings {
display: flex;
justify-content: space-between;
}
#container > .settings > .controls > .search {
margin-right: 1em;
}
#listings > .no-listings {
margin-top: 1em;
}
@ -49,12 +45,11 @@
margin: 0 -1em;
padding: 1em;
/* background-color: var(--row-background); */
background-color: var(--row-background);
}
#listings > .listing:nth-child(2n) {
background-color: var(--muted-border-color);
/* background-color: var(--row-background-alternate); */
background-color: var(--row-background-alternate);
}
#listings > .listing .description {

View File

@ -64,14 +64,15 @@
let language = document.getElementById('language');
if (state.lang === null) {
state.lang = language.dataset.accept;
let cookie = document.cookie
.split(';')
.find(row => row.trim().startsWith('lang='));
if (cookie !== undefined) {
state.lang = decodeURIComponent(cookie.split('=')[1]);
}
}
let cookie = document.cookie
.split(';')
.find(row => row.trim().startsWith('lang='));
if (cookie !== undefined) {
state.lang = decodeURIComponent(cookie.split('=')[1]);
}
language.value = state.lang;
}
function setUpList() {
@ -155,9 +156,19 @@
});
}
function setUpLanguage() {
let language = document.getElementById('language');
language.addEventListener('change', () => {
state.lang = language.value;
document.cookie = `lang=${encodeURIComponent(language.value)};path=/;max-age=31536000;samesite=lax`;
window.location.reload();
});
}
addJsClass();
saveLoadState();
reflectState();
setUpLanguage();
state.list = setUpList();
setUpDataCentreFilter();
setUpCategoryFilter();

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
body {
margin-top: 1em;
margin: 1em;
}
.total {
@ -10,15 +10,10 @@ body {
}
.chart {
height: 50vh;
max-height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.chart svg {
.chart canvas {
max-width: 100%;
max-height: 100%;
}
@ -40,12 +35,11 @@ body {
}
.chart-containers .container table tr {
/* background-color: var(--row-background); */
background-color: var(--row-background);
}
.chart-containers .container table tr:nth-child(2n) {
/* background-color: var(--row-background-alternate); */
background-color: var(--muted-border-color);
background-color: var(--row-background-alternate);
}
.chart-containers .container table tr td {

View File

@ -85,252 +85,42 @@
return newData;
}
function extractData(tableId, extractor = null) {
if (extractor === null) {
extractor = (cols) => {
return {
label: cols[0].innerHTML,
value: Number(cols[1].innerHTML),
};
};
}
function makeChart(tableId, chartId, chartType, combine = false) {
let table = document.getElementById(tableId);
let data = [];
for (let row of table.querySelectorAll('tbody > tr')) {
let cols = row.querySelectorAll('td');
data.push(extractor(cols));
}
return data;
}
function wrap(text) {
text.each(function () {
var text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
x = text.attr("x"),
y = text.attr("y"),
dy = 0, //parseFloat(text.attr("dy")),
tspan = text.text(null)
.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > text.attr('width') - 6) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan")
.attr("x", x)
.attr("y", y)
.attr("dy", ++lineNumber * lineHeight + dy + "em")
.text(word);
}
}
});
}
function makeTreeMap(data, graphId, opts = {}) {
let drawLabels = opts.drawLabels === undefined ? true : opts.drawLabels;
let grouped = opts.grouped === undefined ? false : opts.grouped;
let elem = document.getElementById(graphId);
const [width, height] = [elem.offsetWidth, elem.offsetHeight];
let svg = d3.select(`#${graphId}`)
.append('svg')
.attr('viewBox', `0 0 ${width} ${height}`);
if (grouped) {
d3.treemap()
.size([width, height])
.paddingInner(2)
.paddingTop(4)
.paddingRight(4)
.paddingLeft(4)
.paddingBottom(4)
(data);
} else {
d3.treemap()
.size([width, height])
.paddingInner(2)
(data);
}
let a = 0;
let group = svg.selectAll('g')
.data(data.leaves())
.enter()
.append('g');
let title = (d) => `${d.data.label} (${(d.data.value / d.parent.value * 100).toFixed(2)}% - ${d.data.value.toLocaleString()})`;
group
.append('title')
.text(title);
group
.append('rect')
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.style('fill', d => {
let colour = colours[a];
a += 1;
a %= colours.length;
return colour;
data.push({
x: cols[0].innerText,
y: Number(cols[1].innerText),
});
if (drawLabels) {
svg.selectAll('text')
.data(data.leaves().filter(d => d.data.value / d.parent.value >= .05 ))
.enter()
.append('text')
.attr('x', d => d.x0 + 5)
.attr('y', d => d.y0 + 20)
.attr('width', d => d.x1 - d.x0)
.text(title)
.attr('font-size', '1em')
.attr('fill', 'white')
.call(wrap);
}
if (grouped) {
svg.selectAll('borders')
.data(data.descendants().filter(d => d.depth === 1))
.enter()
.append('rect')
.attr('x', d => d.x0)
.attr('y', d => d.y0)
.attr('width', d => d.x1 - d.x0)
.attr('height', d => d.y1 - d.y0)
.attr('fill', 'none')
.attr('stroke', '#374956');
svg.selectAll('titles')
.data(data.descendants().filter(d => d.depth === 1))
.enter()
.append('text')
.attr('x', d => d.x1 - 2)
.attr('y', d => d.y1 - 2)
.attr('text-anchor', 'end')
.style('transform', d => {
if ((d.x1 - d.x0) < (d.y1 - d.y0)) {
return 'rotate(90deg)';
}
return null;
})
.style('transform-box', 'fill-box')
.style('transform-origin', '95%')
.text(d => d.data[0])
.attr("font-size", d => {
if (d === data) {
return "1em";
}
let width = d.x1 - d.x0, height = d.y1 - d.y0;
return Math.max(Math.min(width/5, height/2, Math.sqrt((width*width + height*height))/10), 9)
})
.attr('fill', 'white');
if (combine) {
data = combineTopN(data, 15);
}
new Chart(
document.getElementById(chartId),
{
type: chartType,
data: {
datasets: [{
data: data.map(entry => entry.y),
backgroundColor: colours,
}],
labels: data.map(entry => entry.x),
},
options: {
borderWidth: chartType === 'doughnut' ? 0 : 2,
...options,
},
},
);
}
function makeBarPlot(data, graphId) {
let elem = document.getElementById(graphId);
const [marginLeft, marginRight, marginTop, marginBottom] = [100, 0, 16, 50];
const [width, height] = [elem.offsetWidth - marginLeft - marginRight, elem.offsetHeight - marginTop - marginBottom];
let svg = d3.select(`#${graphId}`)
.append('svg')
.attr('viewBox', `0 0 ${width + marginLeft + marginRight} ${height + marginTop + marginBottom}`)
.append('g')
.attr('transform', `translate(${marginLeft}, ${marginTop})`);
let x = d3.scaleBand()
.range([0, width])
.domain(data.map(d => d.label))
.padding(0.2);
svg.append('g')
.attr('transform', `translate(0, ${height})`)
.call(d3.axisBottom(x))
.attr('font-size', '1em')
.selectAll('text')
.style('text-anchor', 'middle')
.attr('font-size', '1em');
let y = d3.scaleLinear()
.domain([0, data.map(d => d.value).reduce((max, a) => Math.max(max, a))])
.range([height, 0]);
svg.append('g')
.call(d3.axisLeft(y))
.attr('font-size', '1em')
.selectAll('text')
.attr('font-size', '1em');
let sum = data.map(d => d.value).reduce((total, a) => total + a);
let colourIdx = 0;
let group = svg.selectAll('mybar')
.data(data)
.enter()
.append('g');
group.append('title')
.text(d => `${d.value} (${(d.value / sum * 100).toFixed(2)}%)`);
group.append('rect')
.attr('x', d => x(d.label))
.attr('y', d => y(d.value))
.attr('width', x.bandwidth())
.attr('height', d => height - y(d.value))
.attr('fill', d => {
let colour = colours[colourIdx];
colourIdx += 1;
colourIdx %= colours.length;
return colour;
});
}
makeTreeMap(
d3.hierarchy({
children: extractData('duties'),
}).sum(d => d.value),
'dutiesChart',
);
makeTreeMap(
d3.hierarchy(
d3.group(
extractData(
'hosts',
(cols) => {
return {
label: cols[1].innerHTML,
world: cols[0].innerHTML,
value: Number(cols[2].innerHTML),
};
},
),
d => d.world,
)
).sum(d => d.value),
'hostsChart',
{
drawLabels: false,
grouped: true,
},
);
makeBarPlot(
extractData('hours'),
'hoursChart',
);
makeBarPlot(
extractData('days'),
'daysChart',
);
makeChart('duties', 'dutiesChart', 'pie', true);
makeChart('hosts', 'hostsChart', 'doughnut');
makeChart('hours', 'hoursChart', 'bar');
makeChart('days', 'daysChart', 'bar');
})();

View File

@ -1,15 +1,11 @@
let
sources = import ./nix/sources.nix { };
pkgs = import sources.nixpkgs { overlays = [ (import sources.mozilla) ]; };
rust = pkgs.rustChannelOfTargets "nightly" "2022-12-02" [ "x86_64-unknown-linux-musl" ];
in
pkgs.mkShell {
buildInputs = [
rust
(pkgs.rustChannelOf { date = "2021-10-03"; channel = "nightly"; }).rust
pkgs.gcc
pkgs.glibc
pkgs.patchelf
pkgs.musl
pkgs.musl.dev
];
}

View File

@ -5,10 +5,10 @@
"homepage": "",
"owner": "mozilla",
"repo": "nixpkgs-mozilla",
"rev": "e1f7540fc0a8b989fb8cf701dc4fd7fc76bcf168",
"sha256": "1b6p0rly0rywq60ks84ghc0n5zrqiafc2r64nlbnlkh9whmh5fmj",
"rev": "0510159186dd2ef46e5464484fbdf119393afa58",
"sha256": "1c6r5ldkh71v6acsfhni7f9sxvi7xrqzshcwd8w0hl2rrqyzi58w",
"type": "tarball",
"url": "https://github.com/mozilla/nixpkgs-mozilla/archive/e1f7540fc0a8b989fb8cf701dc4fd7fc76bcf168.tar.gz",
"url": "https://github.com/mozilla/nixpkgs-mozilla/archive/0510159186dd2ef46e5464484fbdf119393afa58.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
},
"niv": {
@ -17,22 +17,22 @@
"homepage": "https://github.com/nmattia/niv",
"owner": "nmattia",
"repo": "niv",
"rev": "82e5cd1ad3c387863f0545d7591512e76ab0fc41",
"sha256": "090l219mzc0gi33i3psgph6s2pwsc8qy4lyrqjdj4qzkvmaj65a7",
"rev": "65a61b147f307d24bfd0a5cd56ce7d7b7cc61d2e",
"sha256": "17mirpsx5wyw262fpsd6n6m47jcgw8k2bwcp1iwdnrlzy4dhcgqh",
"type": "tarball",
"url": "https://github.com/nmattia/niv/archive/82e5cd1ad3c387863f0545d7591512e76ab0fc41.tar.gz",
"url": "https://github.com/nmattia/niv/archive/65a61b147f307d24bfd0a5cd56ce7d7b7cc61d2e.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
},
"nixpkgs": {
"branch": "release-22.05",
"branch": "release-20.03",
"description": "Nix Packages collection",
"homepage": "",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "edaee6c8d8d126dee69eaf1c515f9e39d26515d8",
"sha256": "04krg1idccks34x4p27kfgqg3yl15ihh4j00lg7njxhjms6pjw51",
"rev": "eb73405ecceb1dc505b7cbbd234f8f94165e2696",
"sha256": "06k21wbyhhvq2f1xczszh3c2934p0m02by3l2ixvd6nkwrqklax7",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/edaee6c8d8d126dee69eaf1c515f9e39d26515d8.tar.gz",
"url": "https://github.com/NixOS/nixpkgs/archive/eb73405ecceb1dc505b7cbbd234f8f94165e2696.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
}
}

View File

@ -1,10 +1,10 @@
use std::{
cmp::Ordering,
str::FromStr,
};
use std::borrow::Cow;
use crate::listing::{DutyCategory, DutyType};
pub mod auto_translate;
pub mod duties;
pub mod jobs;
pub mod roulettes;
pub mod territory_names;
pub mod treasure_maps;
pub mod worlds;
pub use self::{
auto_translate::AUTO_TRANSLATE,
@ -16,13 +16,12 @@ pub use self::{
worlds::WORLDS,
};
pub mod auto_translate;
pub mod duties;
pub mod jobs;
pub mod roulettes;
pub mod territory_names;
pub mod treasure_maps;
pub mod worlds;
use std::{
cmp::Ordering,
str::FromStr,
};
use std::borrow::Cow;
use crate::listing::{DutyCategory, DutyType};
#[derive(Debug, Copy, Clone)]
pub enum Language {
@ -42,15 +41,6 @@ impl Language {
}
}
pub fn name(&self) -> &'static str {
match self {
Self::English => "english",
Self::Japanese => "日本語",
Self::German => "deutsch",
Self::French => "français",
}
}
pub fn from_codes(val: Option<&str>) -> Self {
let val = match val {
Some(v) => v,
@ -81,7 +71,7 @@ impl Language {
"ja" => return Self::Japanese,
"de" => return Self::German,
"fr" => return Self::French,
_ => {}
_ => {},
}
}
@ -108,16 +98,6 @@ impl LocalisedText {
}
}
pub fn duty(duty: u32) -> Option<&'static duties::DutyInfo> {
crate::ffxiv::DUTIES.get(&duty)
.or_else(|| old::OLD_DUTIES.get(&duty))
}
pub fn roulette(roulette: u32) -> Option<&'static roulettes::RouletteInfo> {
crate::ffxiv::ROULETTES.get(&roulette)
.or_else(|| old::OLD_ROULETTES.get(&roulette))
}
pub fn duty_name<'a>(duty_type: DutyType, category: DutyCategory, duty: u16, lang: Language) -> Cow<'a, str> {
match (duty_type, category) {
(DutyType::Other, DutyCategory::Fates) => {
@ -151,19 +131,13 @@ pub fn duty_name<'a>(duty_type: DutyType, category: DutyCategory, duty: u16, lan
Language::German => "Himmelssäule",
Language::French => "Pilier des Cieux",
}),
(DutyType::Other, DutyCategory::DeepDungeons) if duty == 3 => return Cow::from(match lang {
Language::English => "Eureka Orthos",
Language::Japanese => "オルト・エウレカ",
Language::German => "Eureka Orthos",
Language::French => "Eurêka Orthos",
}),
(DutyType::Normal, _) => {
if let Some(info) = crate::ffxiv::duty(u32::from(duty)) {
if let Some(info) = crate::ffxiv::DUTIES.get(&u32::from(duty)) {
return Cow::from(info.name.text(&lang));
}
}
(DutyType::Roulette, _) => {
if let Some(info) = roulette(u32::from(duty)) {
if let Some(info) = crate::ffxiv::ROULETTES.get(&u32::from(duty)) {
return Cow::from(info.name.text(&lang));
}
}
@ -179,211 +153,5 @@ pub fn duty_name<'a>(duty_type: DutyType, category: DutyCategory, duty: u16, lan
_ => {}
}
eprintln!("unknown type/category/duty: {:?}/{:?}/{}", duty_type, category, duty);
Cow::from(format!("{:?}", category))
}
mod old {
use std::collections::HashMap;
use crate::ffxiv::{
duties::{ContentKind, DutyInfo},
LocalisedText,
roulettes::RouletteInfo,
};
lazy_static::lazy_static! {
pub static ref OLD_DUTIES: HashMap<u32, DutyInfo> = maplit::hashmap! {
62 => DutyInfo {
name: LocalisedText {
en: "Cape Westwind",
ja: "リットアティン強襲戦",
de: "Kap Westwind",
fr: "Le Cap Vendouest",
},
high_end: false,
content_kind: ContentKind::Trials,
},
83 => DutyInfo {
name: LocalisedText {
en: "The Steps of Faith",
ja: "皇都イシュガルド防衛戦",
de: "Der Schicksalsweg",
fr: "Le Siège de la sainte Cité d'Ishgard",
},
high_end: false,
content_kind: ContentKind::Trials,
},
143 => DutyInfo {
name: LocalisedText {
en: "The Feast (4 on 4 - Training)",
ja: "ザ・フィースト (4対4 / カジュアルマッチ)",
de: "The Feast (4 gegen 4, Übungskampf)",
fr: "The Feast (4x4/entraînement)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
145 => DutyInfo {
name: LocalisedText {
en: "The Feast (4 on 4 - Ranked)",
ja: "ザ・フィースト (4対4 / ランクマッチ)",
de: "The Feast (4 gegen 4, gewertet)",
fr: "The Feast (4x4/classé)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
201 => DutyInfo {
name: LocalisedText {
en: "The Feast (Custom Match - Feasting Grounds)",
ja: "ザ・フィースト (ウルヴズジェイル演習場:カスタムマッチ)",
de: "The Feast (Wolfshöhle: Schaukampf)",
fr: "The Feast (personnalisé/Festin des loups)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
228 => DutyInfo {
name: LocalisedText {
en: "The Feast (4 on 4 - Training)",
ja: "ザ・フィースト (4対4 / カジュアルマッチ)",
de: "The Feast (4 gegen 4, Übungskampf)",
fr: "The Feast (4x4/entraînement)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
230 => DutyInfo {
name: LocalisedText {
en: "The Feast (4 on 4 - Ranked)",
ja: "ザ・フィースト (4対4 / ランクマッチ)",
de: "The Feast (4 gegen 4, gewertet)",
fr: "The Feast (4x4/classé)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
233 => DutyInfo {
name: LocalisedText {
en: "The Feast (Custom Match - Lichenweed)",
ja: "ザ・フィースト (ライケンウィード演習場:カスタムマッチ)",
de: "The Feast (Flechtenhain: Schaukampf)",
fr: "The Feast (personnalisé/Pré-de-lichen)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
476 => DutyInfo {
name: LocalisedText {
en: "The Feast (Team Ranked)",
ja: "ザ・フィースト (チーム用ランクマッチ)",
de: "The Feast (Team, gewertet)",
fr: "The Feast (classé/équipe JcJ)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
478 => DutyInfo {
name: LocalisedText {
en: "The Feast (Ranked)",
ja: "ザ・フィースト (ランクマッチ)",
de: "The Feast (gewertet)",
fr: "The Feast (classé)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
479 => DutyInfo {
name: LocalisedText {
en: "The Feast (Training)",
ja: "ザ・フィースト (カジュアルマッチ)",
de: "The Feast (Übungskampf)",
fr: "The Feast (entraînement)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
480 => DutyInfo {
name: LocalisedText {
en: "The Feast (Custom Match - Crystal Tower)",
ja: "ザ・フィースト (クリスタルタワー演習場:カスタムマッチ)",
de: "The Feast (Kristallturm-Arena: Schaukampf)",
fr: "The Feast (personnalisé/Tour de Cristal)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
580 => DutyInfo {
name: LocalisedText {
en: "The Feast (Team Custom Match - Crystal Tower)",
ja: "ザ・フィースト (クリスタルタワー演習場:チーム用カスタムマッチ)",
de: "The Feast (Kristallturm-Arena: Team-Schaukampf) ",
fr: "The Feast (personnalisé/équipe JcJ/Tour de Cristal)",
},
high_end: false,
content_kind: ContentKind::PvP,
},
776 => DutyInfo {
name: LocalisedText {
en: "The Whorleater (Unreal)",
ja: "幻リヴァイアサン討滅戦",
de: "Traumprüfung - Leviathan",
fr: "Le Briseur de marées (irréel)",
},
high_end: true,
content_kind: ContentKind::Trials,
},
821 => DutyInfo {
name: LocalisedText {
en: "Ultima's Bane (Unreal)",
ja: "幻アルテマウェポン破壊作戦",
de: "Traumprüfung - Ultima",
fr: "Le fléau d'Ultima (irréel)",
},
high_end: true,
content_kind: ContentKind::Trials,
},
875 => DutyInfo {
name: LocalisedText {
en: "Containment Bay S1T7 (Unreal)",
ja: "幻魔神セフィロト討滅戦",
de: "Traumprüfung - Sephirot",
fr: "Unité de contention S1P7 (irréel)",
},
high_end: true,
content_kind: ContentKind::Trials,
},
};
pub static ref OLD_ROULETTES: HashMap<u32, RouletteInfo> = maplit::hashmap! {
11 => RouletteInfo {
name: LocalisedText {
en: "The Feast (Training Match)",
ja: "ザ・フィースト (カジュアルマッチ)",
de: "The Feast (Übungskampf)",
fr: "The Feast (entraînement)",
},
pvp: true,
},
13 => RouletteInfo {
name: LocalisedText {
en: "The Feast (Ranked Match)",
ja: "ザ・フィースト (ランクマッチ)",
de: "The Feast (gewertet)",
fr: "The Feast (classé)",
},
pvp: true,
},
16 => RouletteInfo {
name: LocalisedText {
en: "The Feast (Team Ranked Match)",
ja: "ザ・フィースト (チーム用ランクマッチ)",
de: "The Feast (Team, gewertet)",
fr: "The Feast (classé/équipe JcJ)",
},
pvp: true,
},
};
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -41,7 +41,5 @@ lazy_static::lazy_static! {
36 => ClassJob::Job(Job::BlueMage),
37 => ClassJob::Job(Job::Gunbreaker),
38 => ClassJob::Job(Job::Dancer),
39 => ClassJob::Job(Job::Reaper),
40 => ClassJob::Job(Job::Sage),
};
}

View File

@ -20,10 +20,10 @@ lazy_static::lazy_static! {
},
2 => RouletteInfo {
name: LocalisedText {
en: "Duty Roulette: Level 50/60/70/80 Dungeons",
ja: "コンテンツルーレットレベル50・60・70・80ダンジョン",
de: "Zufallsinhalt: Stufe 50/60/70/80",
fr: "Mission aléatoire : donjons nv 50/60/70/80",
en: "Duty Roulette: Level 50/60/70 Dungeons",
ja: "コンテンツルーレットレベル50・60・70ダンジョン",
de: "Zufallsinhalt: Stufe 50/60/70",
fr: "Mission aléatoire : donjons nv 50/60/70",
},
pvp: false,
},
@ -70,14 +70,14 @@ lazy_static::lazy_static! {
de: "Tagesherausforderung: PvP-Front",
fr: "Challenge quotidien : Front",
},
pvp: true,
pvp: false,
},
8 => RouletteInfo {
name: LocalisedText {
en: "Duty Roulette: Level 90 Dungeons",
ja: "コンテンツルーレット:レベル90ダンジョン",
de: "Zufallsinhalt: Stufe 90",
fr: "Mission aléatoire : donjons nv 90",
en: "Duty Roulette: Level 80 Dungeons",
ja: "コンテンツルーレット:レベル80ダンジョン",
de: "Zufallsinhalt: Stufe 80",
fr: "Mission aléatoire : donjons nv 80",
},
pvp: false,
},
@ -90,6 +90,24 @@ lazy_static::lazy_static! {
},
pvp: false,
},
11 => RouletteInfo {
name: LocalisedText {
en: "The Feast (Training Match)",
ja: "ザ・フィースト (カジュアルマッチ)",
de: "The Feast (Übungskampf)",
fr: "The Feast (entraînement)",
},
pvp: true,
},
13 => RouletteInfo {
name: LocalisedText {
en: "The Feast (Ranked Match)",
ja: "ザ・フィースト (ランクマッチ)",
de: "The Feast (gewertet)",
fr: "The Feast (classé)",
},
pvp: true,
},
15 => RouletteInfo {
name: LocalisedText {
en: "Duty Roulette: Alliance Raids",
@ -99,6 +117,15 @@ lazy_static::lazy_static! {
},
pvp: false,
},
16 => RouletteInfo {
name: LocalisedText {
en: "The Feast (Team Ranked Match)",
ja: "ザ・フィースト (チーム用ランクマッチ)",
de: "The Feast (Team, gewertet)",
fr: "The Feast (classé/équipe JcJ)",
},
pvp: true,
},
17 => RouletteInfo {
name: LocalisedText {
en: "Duty Roulette: Normal Raids",
@ -297,23 +324,5 @@ lazy_static::lazy_static! {
},
pvp: false,
},
40 => RouletteInfo {
name: LocalisedText {
en: "Crystalline Conflict (Casual Match)",
ja: "クリスタルコンフリクト(カジュアルマッチ)",
de: "Crystalline Conflict: Freies Spiel",
fr: "Crystalline Conflict (partie non classée)",
},
pvp: true,
},
41 => RouletteInfo {
name: LocalisedText {
en: "Crystalline Conflict (Ranked Match)",
ja: "クリスタルコンフリクト(ランクマッチ)",
de: "Crystalline Conflict: Gewertetes Spiel",
fr: "Crystalline Conflict (partie classée)",
},
pvp: true,
},
};
}

File diff suppressed because it is too large Load Diff

View File

@ -100,40 +100,10 @@ lazy_static::lazy_static! {
fr: "Carte au trésor en peau de glaucus",
},
16 => LocalisedText {
en: "Ostensibly Special Treasure Map",
en: "Presumably Special Treasure Map",
ja: "古ぼけた地図S2",
de: "Mythenleder-Schatzkarte",
fr: "Carte au trésor inhabituelle II",
},
17 => LocalisedText {
en: "Saigaskin Treasure Map",
ja: "古ぼけた地図G13",
de: "Gajaleder-Schatzkarte",
fr: "Carte au trésor en peau de gaja",
},
18 => LocalisedText {
en: "Kumbhiraskin Treasure Map",
ja: "古ぼけた地図G14",
de: "Kumbhilaleder-Schatzkarte",
fr: "Carte au trésor en peau de kumbhira",
},
19 => LocalisedText {
en: "Ophiotauroskin Treasure Map",
ja: "古ぼけた地図G15",
de: "Ophiotaurosleder-Schatzkarte",
fr: "Carte au trésor en peau d'ophiotauros",
},
20 => LocalisedText {
en: "Potentially Special Treasure Map",
ja: "古ぼけた地図S3",
de: "Legendenleder-Schatzkarte",
fr: "Carte au trésor inhabituelle III",
},
21 => LocalisedText {
en: "Conceivably Special Treasure Map",
ja: "古ぼけた地図S4",
de: "Sagenleder-Schatzkarte",
fr: "Carte au trésor inhabituelle IV",
},
};
}

View File

@ -3,8 +3,6 @@ use ffxiv_types::World;
lazy_static::lazy_static! {
pub static ref WORLDS: HashMap<u32, World> = maplit::hashmap! {
21 => World::Ravana,
22 => World::Bismarck,
23 => World::Asura,
24 => World::Belias,
28 => World::Pandaemonium,
@ -63,9 +61,6 @@ lazy_static::lazy_static! {
82 => World::Mandragora,
83 => World::Louisoix,
85 => World::Spriggan,
86 => World::Sephirot,
87 => World::Sophia,
88 => World::Zurvan,
90 => World::Aegis,
91 => World::Balmung,
92 => World::Durandal,
@ -76,13 +71,5 @@ lazy_static::lazy_static! {
97 => World::Ragnarok,
98 => World::Ridill,
99 => World::Sargatanas,
400 => World::Sagittarius,
401 => World::Phantom,
402 => World::Alpha,
403 => World::Raiden,
404 => World::Marilith,
405 => World::Seraph,
406 => World::Halicarnassus,
407 => World::Maduin,
};
}

View File

@ -16,9 +16,9 @@ pub struct PartyFinderListing {
pub name: SeString,
#[serde(with = "crate::base64_sestring")]
pub description: SeString,
pub created_world: u16,
pub home_world: u16,
pub current_world: u16,
pub created_world: u8,
pub home_world: u8,
pub current_world: u8,
pub category: DutyCategory,
pub duty: u16,
pub duty_type: DutyType,
@ -134,7 +134,7 @@ impl PartyFinderListing {
return false;
}
crate::ffxiv::duty(u32::from(self.duty))
crate::ffxiv::DUTIES.get(&u32::from(self.duty))
.map(|info| info.high_end)
.unwrap_or_default()
}
@ -144,14 +144,14 @@ impl PartyFinderListing {
return 0;
}
crate::ffxiv::duty(u32::from(self.duty))
crate::ffxiv::DUTIES.get(&u32::from(self.duty))
.map(|info| info.content_kind.as_u32())
.unwrap_or_default()
}
pub fn pf_category(&self) -> Option<PartyFinderCategory> {
let duty_type = self.duty_type;
let duty_info = crate::ffxiv::duty(u32::from(self.duty));
let duty_info = crate::ffxiv::DUTIES.get(&u32::from(self.duty));
let duty_category = self.category;
let category = match (duty_type, duty_info, duty_category) {
@ -162,7 +162,6 @@ impl PartyFinderListing {
(DutyType::Normal, _, DutyCategory::GatheringForays) => PartyFinderCategory::GatheringForays,
(DutyType::Other, _, DutyCategory::DeepDungeons) => PartyFinderCategory::DeepDungeons,
(DutyType::Normal, _, DutyCategory::AdventuringForays) => PartyFinderCategory::AdventuringForays,
(DutyType::Normal, _, DutyCategory::VariantAndCriterionDungeonFinder) => PartyFinderCategory::VariantAndCriterionDungeonFinder,
(DutyType::Normal, Some(DutyInfo { high_end: true, .. }), _) => PartyFinderCategory::HighEndDuty,
(DutyType::Normal, Some(DutyInfo { content_kind: ContentKind::Dungeons, .. }), _) => PartyFinderCategory::Dungeons,
(DutyType::Normal, Some(DutyInfo { content_kind: ContentKind::Guildhests, .. }), _) => PartyFinderCategory::Guildhests,
@ -236,7 +235,6 @@ pub enum DutyCategory {
GatheringForays = 1 << 4,
DeepDungeons = 1 << 5,
AdventuringForays = 1 << 6,
VariantAndCriterionDungeonFinder = 1 << 7,
}
impl DutyCategory {
@ -367,8 +365,6 @@ bitflags! {
const BLUE_MAGE = 1 << 25;
const GUNBREAKER = 1 << 26;
const DANCER = 1 << 27;
const REAPER = 1 << 28;
const SAGE = 1 << 29;
}
}
@ -484,14 +480,6 @@ impl JobFlags {
cjs.push(ClassJob::Job(Job::Dancer));
}
if self.contains(Self::REAPER) {
cjs.push(ClassJob::Job(Job::Reaper));
}
if self.contains(Self::SAGE) {
cjs.push(ClassJob::Job(Job::Sage));
}
cjs
}
}
@ -512,12 +500,11 @@ pub enum PartyFinderCategory {
GatheringForays,
DeepDungeons,
AdventuringForays,
VariantAndCriterionDungeonFinder,
None,
}
impl PartyFinderCategory {
pub const ALL: [Self; 16] = [
pub const ALL: [Self; 15] = [
Self::DutyRoulette,
Self::Dungeons,
Self::Guildhests,
@ -532,7 +519,6 @@ impl PartyFinderCategory {
Self::GatheringForays,
Self::DeepDungeons,
Self::AdventuringForays,
Self::VariantAndCriterionDungeonFinder,
Self::None,
];
@ -552,7 +538,6 @@ impl PartyFinderCategory {
Self::GatheringForays => "GatheringForays",
Self::DeepDungeons => "DeepDungeons",
Self::AdventuringForays => "AdventuringForays",
Self::VariantAndCriterionDungeonFinder => "V&C Dungeon Finder",
Self::None => "None",
}
}
@ -643,12 +628,6 @@ impl PartyFinderCategory {
de: "Feldexkursion",
fr: "Missions d'exploration",
},
Self::VariantAndCriterionDungeonFinder => LocalisedText {
en: "V&C Dungeon Finder",
ja: "特殊ダンジョン探索",
de: "Gewölbesuche",
fr: "Donjons spéciaux",
},
Self::None => LocalisedText {
en: "None",
ja: "設定なし",

View File

@ -41,7 +41,7 @@ async fn main() {
if let Err(e) = self::web::start(Arc::new(config)).await {
eprintln!("error: {}", e);
eprintln!(" {:?}", e);
// eprintln!("{}", e.backtrace());
eprintln!("{}", e.backtrace());
}
}

View File

@ -11,30 +11,24 @@ pub struct CachedStatistics {
pub seven_days: Statistics,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Aliases {
#[serde(deserialize_with = "alias_de")]
pub aliases: HashMap<u32, Alias>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Statistics {
pub count: Vec<Count>,
#[serde(default)]
pub aliases: HashMap<u32, Alias>,
#[serde(deserialize_with = "alias_de")]
pub aliases: HashMap<u32, Vec<Alias>>,
pub duties: Vec<DutyInfo>,
pub hosts: Vec<HostInfo>,
pub hours: Vec<HourInfo>,
pub days: Vec<DayInfo>,
}
fn alias_de<'de, D>(de: D) -> std::result::Result<HashMap<u32, Alias>, D::Error>
where D: Deserializer<'de>
fn alias_de<'de, D>(de: D) -> std::result::Result<HashMap<u32, Vec<Alias>>, D::Error>
where D: Deserializer<'de>
{
let aliases: Vec<AliasInfo> = Deserialize::deserialize(de)?;
let map = aliases
.into_iter()
.map(|info| (info.content_id, info.alias))
.map(|info| (info.content_id_lower, info.aliases))
.collect();
Ok(map)
}
@ -49,17 +43,25 @@ impl Statistics {
}
pub fn player_name(&self, cid: &u32) -> Cow<str> {
let alias = match self.aliases.get(cid) {
let aliases = match self.aliases.get(cid) {
Some(a) => a,
None => return "<unknown>".into(),
};
let world = match crate::ffxiv::WORLDS.get(&alias.home_world) {
if aliases.is_empty() {
return "<unknown>".into();
}
let world = match crate::ffxiv::WORLDS.get(&aliases[0].home_world) {
Some(world) => world.name(),
None => "<unknown>",
};
format!("{} @ {}", alias.name.text(), world).into()
format!("{} @ {}", aliases[0].name.text(), world).into()
}
pub fn num_host_listings(&self) -> usize {
self.hosts.iter().map(|info| info.count).sum()
}
}
@ -71,8 +73,8 @@ pub struct Count {
#[derive(Debug, Clone, Deserialize)]
pub struct AliasInfo {
#[serde(rename = "_id")]
pub content_id: u32,
pub alias: Alias,
pub content_id_lower: u32,
pub aliases: Vec<Alias>,
}
#[derive(Debug, Clone, Deserialize)]
@ -106,28 +108,7 @@ impl DutyInfo {
#[derive(Debug, Clone, Deserialize)]
pub struct HostInfo {
#[serde(rename = "_id")]
pub created_world: u32,
pub count: usize,
pub content_ids: Vec<HostInfoInfo>,
}
impl HostInfo {
pub fn num_other(&self) -> usize {
let top15: usize = self.content_ids.iter().map(|info| info.count).sum();
self.count - top15
}
pub fn world_name(&self) -> &'static str {
match crate::ffxiv::WORLDS.get(&self.created_world) {
Some(world) => world.name(),
None => "<unknown>",
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostInfoInfo {
pub content_id: u32,
pub content_id_lower: u32,
pub count: usize,
}

View File

@ -1,17 +1,18 @@
mod stats;
use std::{
cmp::Ordering,
convert::Infallible,
sync::Arc,
time::Duration,
};
use anyhow::{Context, Result};
use anyhow::{Result, Context};
use chrono::Utc;
use mongodb::{
bson::doc,
Client as MongoClient,
Collection,
IndexModel,
bson::doc,
options::{IndexOptions, UpdateOptions},
results::UpdateResult,
};
@ -19,11 +20,10 @@ use tokio::sync::RwLock;
use tokio_stream::StreamExt;
use warp::{
Filter,
Reply,
filters::BoxedFilter,
http::Uri,
Reply,
};
use crate::{
config::Config,
ffxiv::Language,
@ -34,8 +34,6 @@ use crate::{
template::stats::StatsTemplate,
};
mod stats;
pub async fn start(config: Arc<Config>) -> Result<()> {
let state = State::new(Arc::clone(&config)).await?;
@ -151,10 +149,6 @@ fn assets() -> BoxedFilter<(impl Reply, )> {
.or(listings_js())
.or(stats_css())
.or(stats_js())
.or(d3())
.or(pico())
.or(common_js())
.or(list_js())
)
.boxed()
}
@ -208,34 +202,6 @@ fn stats_js() -> BoxedFilter<(impl Reply, )> {
.boxed()
}
fn d3() -> BoxedFilter<(impl Reply, )> {
warp::path("d3.js")
.and(warp::path::end())
.and(warp::fs::file("./assets/d3.v7.min.js"))
.boxed()
}
fn pico() -> BoxedFilter<(impl Reply, )> {
warp::path("pico.css")
.and(warp::path::end())
.and(warp::fs::file("./assets/pico.min.css"))
.boxed()
}
fn common_js() -> BoxedFilter<(impl Reply, )> {
warp::path("common.js")
.and(warp::path::end())
.and(warp::fs::file("./assets/common.js"))
.boxed()
}
fn list_js() -> BoxedFilter<(impl Reply, )> {
warp::path("list.js")
.and(warp::path::end())
.and(warp::fs::file("./assets/list.min.js"))
.boxed()
}
fn index() -> BoxedFilter<(impl Reply, )> {
let route = warp::path::end()
.map(|| warp::redirect(Uri::from_static("/listings")));
@ -270,12 +236,6 @@ fn listings(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
"updated_at": { "$gte": two_hours_ago },
}
},
doc! {
"$match": {
// filter private pfs
"listing.search_area": { "$bitsAllClear": 2 },
}
},
doc! {
"$set": {
"time_left": {
@ -450,11 +410,7 @@ fn contribute_multiple(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
warp::post().and(route).boxed()
}
async fn insert_listing(state: &State, mut listing: PartyFinderListing) -> Result<UpdateResult> {
if listing.created_world >= 1_000 || listing.home_world >= 1_000 || listing.current_world >= 1_000 {
anyhow::bail!("invalid listing");
}
async fn insert_listing(state: &State, listing: PartyFinderListing) -> mongodb::error::Result<UpdateResult> {
let opts = UpdateOptions::builder()
.upsert(true)
.build();
@ -482,5 +438,4 @@ async fn insert_listing(state: &State, mut listing: PartyFinderListing) -> Resul
opts,
)
.await
.context("could not insert record")
}

View File

@ -1,19 +1,12 @@
use anyhow::Result;
use chrono::{Duration, Utc};
use mongodb::bson::{Document, doc};
use mongodb::options::AggregateOptions;
use tokio_stream::StreamExt;
use crate::stats::{Aliases, Statistics};
use crate::stats::Statistics;
use crate::web::State;
lazy_static::lazy_static! {
static ref QUERY: [Document; 2] = [
doc! {
"$match": {
// filter private pfs
"listing.search_area": { "$bitsAllClear": 2 },
}
},
static ref QUERY: [Document; 1] = [
doc! {
"$facet": {
"count": [
@ -21,6 +14,19 @@ lazy_static::lazy_static! {
"$count": "count",
},
],
"aliases": [
{
"$group": {
"_id": "$listing.content_id_lower",
"aliases": {
"$addToSet": {
"name": "$listing.name",
"home_world": "$listing.home_world",
},
},
}
}
],
"duties": [
{
"$group": {
@ -43,42 +49,20 @@ lazy_static::lazy_static! {
"hosts": [
{
"$group": {
"_id": {
"world": "$listing.created_world",
"content_id": "$listing.content_id_lower",
"_id": "$listing.content_id_lower",
"count": {
"$sum": 1
},
"count": { "$sum": 1 },
}
},
{
"$sort": {
"count": -1,
"count": -1
}
},
{
"$group": {
"_id": "$_id.world",
"count": {
"$sum": "$count",
},
"content_ids": {
"$push": {
"content_id": "$_id.content_id",
"count": "$count",
}
}
}
},
{
"$addFields": {
"content_ids": {
"$slice": ["$content_ids", 0, 15],
},
}
},
{
"$sort": { "count": -1 }
},
"$limit": 15
}
],
"hours": [
{
@ -117,31 +101,6 @@ lazy_static::lazy_static! {
}
},
];
static ref ALIASES_QUERY: [Document; 1] = [
doc! {
"$facet": {
"aliases": [
{
"$sort": {
"created_at": -1,
}
},
{
"$group": {
"_id": "$listing.content_id_lower",
"alias": {
"$first": {
"name": "$listing.name",
"home_world": "$listing.home_world",
},
},
}
}
],
},
},
];
}
pub async fn get_stats(state: &State) -> Result<Statistics> {
@ -166,34 +125,10 @@ pub async fn get_stats_seven_days(state: &State) -> Result<Statistics> {
async fn get_stats_internal(state: &State, docs: impl IntoIterator<Item = Document>) -> Result<Statistics> {
let mut cursor = state
.collection()
.aggregate(docs, AggregateOptions::builder()
.allow_disk_use(true)
.build())
.aggregate(docs, None)
.await?;
let doc = cursor.try_next().await?;
let doc = doc.ok_or_else(|| anyhow::anyhow!("missing document"))?;
let mut stats: Statistics = mongodb::bson::from_document(doc)?;
let ids: Vec<u32> = stats.hosts.iter().flat_map(|host| host.content_ids.iter().map(|entry| entry.content_id)).collect();
let mut aliases_query: Vec<Document> = ALIASES_QUERY.iter().cloned().collect();
aliases_query.insert(0, doc! {
"$match": {
"listing.content_id_lower": {
"$in": ids,
}
}
});
let mut cursor = state
.collection()
.aggregate(aliases_query, AggregateOptions::builder()
.allow_disk_use(true)
.build())
.await?;
let doc = cursor.try_next().await?;
let doc = doc.ok_or_else(|| anyhow::anyhow!("missing document"))?;
let aliases: Aliases = mongodb::bson::from_document(doc)?;
stats.aliases = aliases.aliases;
let stats = mongodb::bson::from_document(doc)?;
Ok(stats)
}

View File

@ -1,43 +1,11 @@
<!doctype html>
<html lang="{{ lang.code() }}" class="no-js" data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/assets/minireset.css"/>
<link rel="stylesheet" href="/assets/pico.css"/>
<script defer src="/assets/common.js"></script>
{%- block head %}{% endblock -%}
</head>
<body>
<nav class="container">
<ul>
<li><strong><a href="/" class="contrast">xivpf</a></strong></li>
</ul>
<ul>
<li>
<a href="https://www.patreon.com/join/lojewalo">patreon</a>
</li>
<li role="list" dir="rtl">
<a href="javascript:void(0)" aria-haspopup="listbox">stats</a>
<ul role="listbox">
<li><a href="/stats">all time</a></li>
<li><a href="/stats/7days">7 days</a></li>
</ul>
</li>
<li role="list" dir="rtl">
<a href="javascript:void(0)" aria-haspopup="listbox">{{ lang.name() }}</a>
<ul role="listbox" aria-haspopup="listbox" id="language" data-accept="{{ lang.code() }}">
<li><a href="javascript:void(0)" data-value="en">english</a></li>
<li><a href="javascript:void(0)" data-value="ja">日本語</a></li>
<li><a href="javascript:void(0)" data-value="de">deutsch</a></li>
<li><a href="javascript:void(0)" data-value="fr">français</a></li>
</ul>
</li>
</ul>
</nav>
<div class="container">
{% block body %}{% endblock %}
</div>
</body>
<html lang="{{ lang.code() }}" class="no-js">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/assets/minireset.css"/>
{%- block head %}{% endblock -%}
</head>
<body>{% block body %}{% endblock %}</body>
</html>

View File

@ -1,54 +1,50 @@
{% extends "_frame.html" %}
{% block title -%}
xivpf - listings
Remote Party Finder
{%- endblock %}
{% block head %}
<link rel="stylesheet" href="/assets/common.css"/>
<link rel="stylesheet" href="/assets/listings.css"/>
<script defer src="/assets/list.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/list.js/2.3.1/list.min.js"></script>
<script defer src="/assets/listings.js"></script>
{% endblock %}
{% block body %}
<div id="container">
<div class="requires-js settings">
<div class="controls">
<input type="search" class="search" placeholder="search"/>
<div class="left">
<input type="search" class="search" placeholder="Search"/>
<select id="data-centre-filter">
<option value="All">all</option>
<optgroup label="north america">
<option value="Aether">aether</option>
<option value="Crystal">crystal</option>
<option value="Dynamis">dynamis</option>
<option value="Primal">primal</option>
<option value="All">All</option>
<optgroup label="North America">
<option value="Aether">Aether</option>
<option value="Crystal">Crystal</option>
<option value="Primal">Primal</option>
</optgroup>
<optgroup label="europe">
<option value="Chaos">chaos</option>
<option value="Light">light</option>
<optgroup label="Europe">
<option value="Chaos">Chaos</option>
<option value="Light">Light</option>
</optgroup>
<optgroup label="japan">
<option value="Elemental">elemental</option>
<option value="Gaia">gaia</option>
<option value="Mana">mana</option>
<option value="Meteor">meteor</option>
<optgroup label="Japan">
<option value="Elemental">Elemental</option>
<option value="Gaia">Gaia</option>
<option value="Mana">Mana</option>
</optgroup>
<optgroup label="oceania">
<option value="Materia">materia</option>
<optgroup label="Oceania">
<option disabled value="">Not yet lmao</option>
</optgroup>
</select>
</div>
<div>
<details class="filter-controls">
<summary>advanced</summary>
<summary>Advanced</summary>
<div>
<div class="control">
<label>
Categories
<select multiple id="category-filter">
{%- for category in PartyFinderCategory::ALL %}
<option value="{{ category.as_str() }}">{{ category.name().text(lang) }}</option>
<option value="{{ category.as_str() }}">{{ category.name().text(lang) }}</option>
{%- endfor %}
</select>
</label>
@ -56,6 +52,14 @@ xivpf - listings
</div>
</details>
</div>
<div class="right">
<select id="language" data-accept="{{ lang.code() }}">
<option value="en">English</option>
<option value="ja">日本語</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
</select>
</div>
</div>
<div id="listings" class="list">
{%- if containers.is_empty() %}
@ -64,10 +68,10 @@ xivpf - listings
{%- for container in containers %}
{%- let listing = container.listing.borrow() %}
<div
class="listing"
data-id="{{ listing.id }}"
data-centre="{{ listing.data_centre_name().unwrap_or_default() }}"
data-pf-category="{{ listing.html_pf_category() }}">
class="listing"
data-id="{{ listing.id }}"
data-centre="{{ listing.data_centre_name().unwrap_or_default() }}"
data-pf-category="{{ listing.html_pf_category() }}">
<div class="left">
{%- let duty_class %}
{%- if listing.is_cross_world() %}

View File

@ -1,13 +1,13 @@
{% extends "_frame.html" %}
{% block title -%}
xivpf - stats
Remote Party Finder
{%- endblock %}
{% block head %}
<link rel="stylesheet" href="/assets/common.css"/>
<link rel="stylesheet" href="/assets/stats.css"/>
<script defer src="/assets/d3.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
<script defer src="/assets/stats.js"></script>
{% endblock %}
@ -19,7 +19,8 @@ xivpf - stats
<div class="chart-containers">
<div class="container">
<h1>Top categories</h1>
<div id="dutiesChart" class="chart">
<div class="chart">
<canvas id="dutiesChart"></canvas>
</div>
<details>
<summary>Details</summary>
@ -44,33 +45,29 @@ xivpf - stats
<div class="container">
<h1>Top hosts</h1>
<div id="hostsChart" class="chart">
<div class="chart">
<canvas id="hostsChart"></canvas>
</div>
<details>
<summary>Details</summary>
<table id="hosts">
<thead>
<tr>
<th>World (created)</th>
<th>Name</th>
<th>Count</th>
</tr>
<tr>
<th>Name</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{%- for info in stats.hosts %}
{%- for entry in info.content_ids %}
<tr>
<td>{{ info.world_name() }}</td>
<td>{{ stats.player_name(entry.content_id) }}</td>
<td>{{ entry.count }}</td>
</tr>
{%- endfor %}
<tr>
<td>{{ info.world_name() }}</td>
<td>Other</td>
<td>{{ info.num_other() }}
</tr>
{%- endfor %}
{%- for info in stats.hosts %}
<tr>
<td>{{ stats.player_name(info.content_id_lower) }}</td>
<td>{{ info.count }}</td>
</tr>
{%- endfor %}
<tr>
<td>Other</td>
<td>{{ stats.num_listings() - stats.num_host_listings() }}</td>
</tr>
</tbody>
</table>
</details>
@ -78,7 +75,8 @@ xivpf - stats
<div class="container">
<h1>Top hours (UTC)</h1>
<div id="hoursChart" class="chart">
<div class="chart">
<canvas id="hoursChart"></canvas>
</div>
<details>
<summary>Details</summary>
@ -103,7 +101,8 @@ xivpf - stats
<div class="container">
<h1>Top days (UTC)</h1>
<div id="daysChart" class="chart">
<div class="chart">
<canvas id="daysChart"></canvas>
</div>
<details>
<summary>Details</summary>