Add game version management system with manifest support

Features:
- Version dropdown in single/ZIP upload (9.3.0, 9.2.0, etc.)
- Patch-compatible matching (9.3.x assets work with 9.3.0)
- manifest.json/xml support for automatic metadata detection
- Smart category auto-detection from folder structure
- Version field stored in GameAssets table

Manifest support:
- JSON format with gameVersion, category, assets array
- Per-file metadata overrides (type, required, description)
- Auto-detect falls back if no manifest present
- See ASSET-MANIFEST-SPECIFICATION.md for full spec

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-02-20 09:55:05 -08:00
parent f289cdfce9
commit dd2c23000f
21 changed files with 1409 additions and 32 deletions

View File

@@ -0,0 +1,966 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RR3CommunityServer.Data;
#nullable disable
namespace RR3CommunityServer.Migrations
{
[DbContext(typeof(RR3DbContext))]
[Migration("20260220175450_AddGameVersioning")]
partial class AddGameVersioning
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("RR3CommunityServer.Data.Car", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Available")
.HasColumnType("INTEGER");
b.Property<int>("BasePerformanceRating")
.HasColumnType("INTEGER");
b.Property<string>("CarId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CashPrice")
.HasColumnType("INTEGER");
b.Property<string>("ClassType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CustomAuthor")
.HasColumnType("TEXT");
b.Property<string>("CustomVersion")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<int>("GoldPrice")
.HasColumnType("INTEGER");
b.Property<bool>("IsCustom")
.HasColumnType("INTEGER");
b.Property<string>("Manufacturer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Cars");
b.HasData(
new
{
Id = 1,
Available = true,
BasePerformanceRating = 45,
CarId = "nissan_silvia_s15",
CashPrice = 25000,
ClassType = "C",
GoldPrice = 0,
IsCustom = false,
Manufacturer = "Nissan",
Name = "Nissan Silvia Spec-R",
Year = 0
},
new
{
Id = 2,
Available = true,
BasePerformanceRating = 58,
CarId = "ford_focus_rs",
CashPrice = 85000,
ClassType = "B",
GoldPrice = 150,
IsCustom = false,
Manufacturer = "Ford",
Name = "Ford Focus RS",
Year = 0
},
new
{
Id = 3,
Available = true,
BasePerformanceRating = 72,
CarId = "porsche_911_gt3",
CashPrice = 0,
ClassType = "A",
GoldPrice = 350,
IsCustom = false,
Manufacturer = "Porsche",
Name = "Porsche 911 GT3 RS",
Year = 0
},
new
{
Id = 4,
Available = true,
BasePerformanceRating = 88,
CarId = "ferrari_488_gtb",
CashPrice = 0,
ClassType = "S",
GoldPrice = 750,
IsCustom = false,
Manufacturer = "Ferrari",
Name = "Ferrari 488 GTB",
Year = 0
},
new
{
Id = 5,
Available = true,
BasePerformanceRating = 105,
CarId = "mclaren_p1_gtr",
CashPrice = 0,
ClassType = "R",
GoldPrice = 1500,
IsCustom = false,
Manufacturer = "McLaren",
Name = "McLaren P1 GTR",
Year = 0
});
});
modelBuilder.Entity("RR3CommunityServer.Data.CarUpgrade", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CarId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CashCost")
.HasColumnType("INTEGER");
b.Property<int>("Level")
.HasColumnType("INTEGER");
b.Property<int>("PerformanceIncrease")
.HasColumnType("INTEGER");
b.Property<string>("UpgradeType")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CarUpgrades");
b.HasData(
new
{
Id = 1,
CarId = "nissan_silvia_s15",
CashCost = 5000,
Level = 1,
PerformanceIncrease = 3,
UpgradeType = "engine"
},
new
{
Id = 2,
CarId = "nissan_silvia_s15",
CashCost = 3000,
Level = 1,
PerformanceIncrease = 2,
UpgradeType = "tires"
},
new
{
Id = 3,
CarId = "nissan_silvia_s15",
CashCost = 4000,
Level = 1,
PerformanceIncrease = 2,
UpgradeType = "suspension"
},
new
{
Id = 4,
CarId = "nissan_silvia_s15",
CashCost = 3500,
Level = 1,
PerformanceIncrease = 2,
UpgradeType = "brakes"
},
new
{
Id = 5,
CarId = "nissan_silvia_s15",
CashCost = 4500,
Level = 1,
PerformanceIncrease = 3,
UpgradeType = "drivetrain"
});
});
modelBuilder.Entity("RR3CommunityServer.Data.CareerProgress", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<double>("BestTime")
.HasColumnType("REAL");
b.Property<bool>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("EventName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SeriesName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("StarsEarned")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("CareerProgress");
});
modelBuilder.Entity("RR3CommunityServer.Data.CatalogItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Available")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Price")
.HasColumnType("TEXT");
b.Property<string>("Sku")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("CatalogItems");
b.HasData(
new
{
Id = 1,
Available = true,
Name = "1000 Gold",
Price = 0.99m,
Sku = "com.ea.rr3.gold_1000",
Type = "currency"
},
new
{
Id = 2,
Available = true,
Name = "Starter Car",
Price = 0m,
Sku = "com.ea.rr3.car_tier1",
Type = "car"
},
new
{
Id = 3,
Available = true,
Name = "Engine Upgrade",
Price = 4.99m,
Sku = "com.ea.rr3.upgrade_engine",
Type = "upgrade"
},
new
{
Id = 4,
Available = true,
Name = "100 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_100",
Type = "currency"
},
new
{
Id = 5,
Available = true,
Name = "500 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_500",
Type = "currency"
},
new
{
Id = 6,
Available = true,
Name = "1000 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_1000",
Type = "currency"
},
new
{
Id = 7,
Available = true,
Name = "5000 Gold",
Price = 0m,
Sku = "com.ea.rr3.gold_5000",
Type = "currency"
});
});
modelBuilder.Entity("RR3CommunityServer.Data.DailyReward", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("CashAmount")
.HasColumnType("INTEGER");
b.Property<bool>("Claimed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ClaimedAt")
.HasColumnType("TEXT");
b.Property<int>("GoldAmount")
.HasColumnType("INTEGER");
b.Property<DateTime>("RewardDate")
.HasColumnType("TEXT");
b.Property<int>("Streak")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("DailyRewards");
});
modelBuilder.Entity("RR3CommunityServer.Data.Device", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HardwareId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Devices");
});
modelBuilder.Entity("RR3CommunityServer.Data.GameAsset", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessCount")
.HasColumnType("INTEGER");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("AssetType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CarId")
.HasColumnType("TEXT");
b.Property<string>("Category")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long?>("CompressedSize")
.HasColumnType("INTEGER");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CustomAuthor")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<DateTime>("DownloadedAt")
.HasColumnType("TEXT");
b.Property<string>("EaCdnPath")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("FileSha256")
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<bool>("IsAvailable")
.HasColumnType("INTEGER");
b.Property<bool>("IsCustomContent")
.HasColumnType("INTEGER");
b.Property<bool>("IsRequired")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastAccessedAt")
.HasColumnType("TEXT");
b.Property<string>("LocalPath")
.HasColumnType("TEXT");
b.Property<string>("Md5Hash")
.HasColumnType("TEXT");
b.Property<string>("OriginalUrl")
.HasColumnType("TEXT");
b.Property<string>("TrackId")
.HasColumnType("TEXT");
b.Property<DateTime>("UploadedAt")
.HasColumnType("TEXT");
b.Property<string>("Version")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("GameAssets");
});
modelBuilder.Entity("RR3CommunityServer.Data.ModPack", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Author")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CarIds")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<int>("DownloadCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PackId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<double>("Rating")
.HasColumnType("REAL");
b.Property<string>("TrackIds")
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ModPacks");
});
modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CarId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CarName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ClassType")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Manufacturer")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PerformanceRating")
.HasColumnType("INTEGER");
b.Property<DateTime>("PurchasedAt")
.HasColumnType("TEXT");
b.Property<string>("PurchasedUpgrades")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("UpgradeLevel")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("OwnedCars");
});
modelBuilder.Entity("RR3CommunityServer.Data.Purchase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ItemId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OrderId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<decimal>("Price")
.HasColumnType("TEXT");
b.Property<DateTime>("PurchaseTime")
.HasColumnType("TEXT");
b.Property<string>("Sku")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SynergyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Purchases");
});
modelBuilder.Entity("RR3CommunityServer.Data.Session", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SynergyId")
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Sessions");
});
modelBuilder.Entity("RR3CommunityServer.Data.TimeTrial", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("Active")
.HasColumnType("INTEGER");
b.Property<string>("CarName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("CashReward")
.HasColumnType("INTEGER");
b.Property<DateTime>("EndDate")
.HasColumnType("TEXT");
b.Property<int>("GoldReward")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("StartDate")
.HasColumnType("TEXT");
b.Property<double>("TargetTime")
.HasColumnType("REAL");
b.Property<string>("TrackName")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("TimeTrials");
b.HasData(
new
{
Id = 1,
Active = true,
CarName = "Any Car",
CashReward = 10000,
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3387),
GoldReward = 50,
Name = "Daily Sprint Challenge",
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3384),
TargetTime = 90.5,
TrackName = "Silverstone National"
},
new
{
Id = 2,
Active = true,
CarName = "Any Car",
CashReward = 25000,
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
GoldReward = 100,
Name = "Speed Demon Trial",
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
TargetTime = 120.0,
TrackName = "Dubai Autodrome"
});
});
modelBuilder.Entity("RR3CommunityServer.Data.TimeTrialResult", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("BeatTarget")
.HasColumnType("INTEGER");
b.Property<int>("CashEarned")
.HasColumnType("INTEGER");
b.Property<int>("GoldEarned")
.HasColumnType("INTEGER");
b.Property<DateTime>("SubmittedAt")
.HasColumnType("TEXT");
b.Property<double>("TimeSeconds")
.HasColumnType("REAL");
b.Property<int>("TimeTrialId")
.HasColumnType("INTEGER");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("TimeTrialResults");
});
modelBuilder.Entity("RR3CommunityServer.Data.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("Cash")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceId")
.HasColumnType("TEXT");
b.Property<int?>("Experience")
.HasColumnType("INTEGER");
b.Property<int?>("Gold")
.HasColumnType("INTEGER");
b.Property<int?>("Level")
.HasColumnType("INTEGER");
b.Property<string>("Nickname")
.HasColumnType("TEXT");
b.Property<int?>("Reputation")
.HasColumnType("INTEGER");
b.Property<string>("SynergyId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("RR3CommunityServer.Models.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EmailVerificationToken")
.HasColumnType("TEXT");
b.Property<bool>("EmailVerified")
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("PasswordResetExpiry")
.HasColumnType("TEXT");
b.Property<string>("PasswordResetToken")
.HasColumnType("TEXT");
b.Property<int?>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("RR3CommunityServer.Models.DeviceAccount", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.HasColumnType("TEXT");
b.Property<DateTime>("LastUsedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LinkedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AccountId");
b.ToTable("DeviceAccounts");
});
modelBuilder.Entity("RR3CommunityServer.Models.UserSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("LastUpdated")
.HasColumnType("TEXT");
b.Property<string>("Mode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("UserSettings");
});
modelBuilder.Entity("RR3CommunityServer.Data.CareerProgress", b =>
{
b.HasOne("RR3CommunityServer.Data.User", null)
.WithMany("CareerProgress")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RR3CommunityServer.Data.OwnedCar", b =>
{
b.HasOne("RR3CommunityServer.Data.User", null)
.WithMany("OwnedCars")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RR3CommunityServer.Models.Account", b =>
{
b.HasOne("RR3CommunityServer.Data.User", "User")
.WithMany()
.HasForeignKey("UserId");
b.Navigation("User");
});
modelBuilder.Entity("RR3CommunityServer.Models.DeviceAccount", b =>
{
b.HasOne("RR3CommunityServer.Models.Account", "Account")
.WithMany("LinkedDevices")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
});
modelBuilder.Entity("RR3CommunityServer.Data.User", b =>
{
b.Navigation("CareerProgress");
b.Navigation("OwnedCars");
});
modelBuilder.Entity("RR3CommunityServer.Models.Account", b =>
{
b.Navigation("LinkedDevices");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RR3CommunityServer.Migrations
{
/// <inheritdoc />
public partial class AddGameVersioning : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3387), new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3384) });
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395), new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395) });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2221), new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2218) });
migrationBuilder.UpdateData(
table: "TimeTrials",
keyColumn: "Id",
keyValue: 2,
columns: new[] { "EndDate", "StartDate" },
values: new object[] { new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228), new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228) });
}
}
}

View File

@@ -711,10 +711,10 @@ namespace RR3CommunityServer.Migrations
Active = true,
CarName = "Any Car",
CashReward = 10000,
EndDate = new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2221),
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3387),
GoldReward = 50,
Name = "Daily Sprint Challenge",
StartDate = new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2218),
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3384),
TargetTime = 90.5,
TrackName = "Silverstone National"
},
@@ -724,10 +724,10 @@ namespace RR3CommunityServer.Migrations
Active = true,
CarName = "Any Car",
CashReward = 25000,
EndDate = new DateTime(2026, 2, 26, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228),
EndDate = new DateTime(2026, 2, 27, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
GoldReward = 100,
Name = "Speed Demon Trial",
StartDate = new DateTime(2026, 2, 19, 23, 30, 24, 984, DateTimeKind.Utc).AddTicks(2228),
StartDate = new DateTime(2026, 2, 20, 17, 54, 50, 315, DateTimeKind.Utc).AddTicks(3395),
TargetTime = 120.0,
TrackName = "Dubai Autodrome"
});

View File

@@ -74,7 +74,12 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="zip-tab" data-bs-toggle="tab" data-bs-target="#zip" type="button" role="tab">
📦 ZIP Bulk Upload
📦 ZIP Upload
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="url-tab" data-bs-toggle="tab" data-bs-target="#url" type="button" role="tab">
🌐 URL Download
</button>
</li>
</ul>
@@ -132,7 +137,30 @@
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<label for="gameVersion" class="form-label">Game Version</label>
<select class="form-select" id="gameVersion" name="gameVersion" required>
<option value="">Select version...</option>
<option value="9.3.0">9.3.0</option>
<option value="9.2.0">9.2.0</option>
<option value="9.1.0">9.1.0</option>
<option value="9.0.0">9.0.0</option>
<option value="8.9.0">8.9.0</option>
<option value="universal">Universal (All Versions)</option>
</select>
<small class="text-muted">Patch-compatible: 9.3.x works with 9.3.0</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2" placeholder="Brief description of this asset..."></textarea>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">Options</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequired" name="isRequired" checked>
<label class="form-check-label" for="isRequired">
@@ -142,10 +170,6 @@
</div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="2" placeholder="Brief description of this asset..."></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Asset
</button>
@@ -156,19 +180,35 @@
<div class="tab-pane fade" id="zip" role="tabpanel">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> <strong>ZIP Upload:</strong>
Folder structure preserved • Auto MD5 calculation • Existing assets updated
Folder structure preserved • Auto MD5 calculation • Manifest.json support
</div>
<form method="post" enctype="multipart/form-data" asp-page-handler="UploadZip">
<div class="mb-3">
<label for="zipFile" class="form-label">ZIP Archive</label>
<input class="form-control" type="file" id="zipFile" name="zipFile" accept=".zip" required>
<small class="text-muted">Example: cars/porsche_911.dat → /cars/porsche_911.dat</small>
<small class="text-muted">Include manifest.json for auto-detection • Example: cars/porsche_911.dat → /cars/porsche_911.dat</small>
</div>
<div class="row">
<div class="col-md-8">
<div class="col-md-4">
<div class="mb-3">
<label for="zipGameVersion" class="form-label">Game Version</label>
<select class="form-select" id="zipGameVersion" name="gameVersion" required>
<option value="">Detect from manifest...</option>
<option value="9.3.0">9.3.0 (Latest)</option>
<option value="9.2.0">9.2.0</option>
<option value="9.1.0">9.1.0</option>
<option value="9.0.0">9.0.0</option>
<option value="8.9.0">8.9.0</option>
<option value="universal">Universal</option>
</select>
<small class="text-muted">Or specify in manifest.json</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="baseCategory" class="form-label">Base Category</label>
<select class="form-select" id="baseCategory" name="baseCategory">
<option value="auto">🤖 Auto-detect</option>
<option value="base">Base Assets</option>
<option value="cars">Cars</option>
<option value="tracks">Tracks</option>
@@ -183,11 +223,11 @@
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<label class="form-label d-block">Options</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequiredZip" name="isRequired" checked>
<label class="form-check-label" for="isRequiredZip">
All required
Mark as required
</label>
</div>
</div>
@@ -198,6 +238,55 @@
</button>
</form>
</div>
<!-- URL Download Tab -->
<div class="tab-pane fade" id="url" role="tabpanel">
<div class="alert alert-success">
<i class="bi bi-cloud-arrow-down"></i> <strong>Direct Download:</strong>
Server downloads ZIP directly • No browser upload needed • Perfect for large files
</div>
<form method="post" asp-page-handler="DownloadZip">
<div class="mb-3">
<label for="zipUrl" class="form-label">ZIP File URL</label>
<input type="url" class="form-control" id="zipUrl" name="zipUrl"
placeholder="https://example.com/assets/rr3-cars-pack.zip" required>
<small class="text-muted">Direct link to ZIP file (http:// or https://)</small>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="baseCategoryUrl" class="form-label">Base Category (optional)</label>
<select class="form-select" id="baseCategoryUrl" name="baseCategory">
<option value="base">Auto-Detect (Smart)</option>
<option value="cars">Cars</option>
<option value="tracks">Tracks</option>
<option value="audio">Audio</option>
<option value="textures">Textures</option>
<option value="ui">UI</option>
<option value="events">Events</option>
<option value="dlc">DLC</option>
<option value="updates">Updates</option>
</select>
<small class="text-muted">System will auto-detect categories from folder names</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label class="form-label d-block">&nbsp;</label>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isRequiredUrl" name="isRequired" checked>
<label class="form-check-label" for="isRequiredUrl">
All required
</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-download"></i> Download and Extract
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
using System.Security.Cryptography;
using System.IO.Compression;
using System.Text.Json;
namespace RR3CommunityServer.Pages;
@@ -46,6 +47,7 @@ public class AssetsModel : PageModel
string category,
string assetType,
bool isRequired,
string gameVersion,
string? description)
{
try
@@ -108,6 +110,7 @@ public class AssetsModel : PageModel
existingAsset.IsRequired = isRequired;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
Message = $"Asset '{fileName}' updated successfully!";
@@ -128,6 +131,7 @@ public class AssetsModel : PageModel
IsRequired = isRequired,
Description = description,
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -154,6 +158,7 @@ public class AssetsModel : PageModel
public async Task<IActionResult> OnPostUploadZipAsync(
IFormFile zipFile,
string baseCategory,
string? gameVersion,
bool isRequired)
{
try
@@ -190,6 +195,47 @@ public class AssetsModel : PageModel
int extractedCount = 0;
int skippedCount = 0;
var errors = new List<string>();
// Try to parse manifest file if exists
AssetManifest? manifest = null;
using (var archive = ZipFile.OpenRead(tempZipPath))
{
var manifestEntry = archive.Entries.FirstOrDefault(e =>
e.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
e.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase));
if (manifestEntry != null)
{
try
{
using var stream = manifestEntry.Open();
using var reader = new StreamReader(stream);
var manifestContent = await reader.ReadToEndAsync();
if (manifestEntry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
manifest = JsonSerializer.Deserialize<AssetManifest>(manifestContent,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
if (manifest != null)
{
_logger.LogInformation("Loaded manifest: Version={Version}, GameVersion={GameVersion}",
manifest.Version, manifest.GameVersion);
// Override parameters from manifest if provided
if (!string.IsNullOrEmpty(manifest.GameVersion))
gameVersion = manifest.GameVersion;
if (!string.IsNullOrEmpty(manifest.Category) && baseCategory == "auto")
baseCategory = manifest.Category;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse manifest file, using defaults");
}
}
}
// Extract ZIP and process each file
using (var archive = ZipFile.OpenRead(tempZipPath))
@@ -198,18 +244,31 @@ public class AssetsModel : PageModel
{
try
{
// Skip directories
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/"))
// Skip directories and manifest files
if (string.IsNullOrEmpty(entry.Name) || entry.FullName.EndsWith("/") ||
entry.Name.Equals("manifest.json", StringComparison.OrdinalIgnoreCase) ||
entry.Name.Equals("manifest.xml", StringComparison.OrdinalIgnoreCase))
continue;
// Determine category from path in ZIP
// Check if this file has manifest metadata
ManifestAsset? manifestAsset = manifest?.Assets?.FirstOrDefault(a =>
a.File.Replace("\\", "/").Equals(entry.FullName.Replace("\\", "/"),
StringComparison.OrdinalIgnoreCase));
// Determine category from manifest or path in ZIP
var pathParts = entry.FullName.Split('/', '\\');
var category = baseCategory;
var category = manifestAsset?.Category ?? baseCategory;
// If ZIP has folders, use first folder as subcategory
if (pathParts.Length > 1)
// Auto-detect if category is "auto"
if (category == "auto")
{
category = Path.Combine(baseCategory, pathParts[0]);
category = SmartDetectCategory(entry.FullName) ?? "base";
}
// If ZIP has folders and no manifest, use first folder as subcategory
if (manifestAsset == null && pathParts.Length > 1 && category != "auto")
{
category = Path.Combine(category, pathParts[0]);
}
// Create category subdirectory
@@ -233,6 +292,11 @@ public class AssetsModel : PageModel
// Build EA CDN path from ZIP structure
var eaCdnPath = "/" + entry.FullName.Replace("\\", "/");
// Determine asset type from manifest or filename
var assetType = manifestAsset?.Type ?? DetermineAssetType(fileName);
var required = manifestAsset?.Required ?? isRequired;
var description = manifestAsset?.Description;
// Check if asset already exists
var existingAsset = await _context.GameAssets
@@ -247,8 +311,11 @@ public class AssetsModel : PageModel
existingAsset.Md5Hash = md5Hash;
existingAsset.FileSha256 = sha256Hash;
existingAsset.Category = category;
existingAsset.IsRequired = isRequired;
existingAsset.AssetType = assetType;
existingAsset.IsRequired = required;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
skippedCount++;
}
@@ -264,10 +331,11 @@ public class AssetsModel : PageModel
Md5Hash = md5Hash,
FileSha256 = sha256Hash,
Category = category,
AssetType = DetermineAssetType(fileName),
IsRequired = isRequired,
Description = $"Extracted from {zipFile.FileName}",
AssetType = assetType,
IsRequired = required,
Description = description ?? $"Extracted from {zipFile.FileName}",
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -446,6 +514,36 @@ public class AssetsModel : PageModel
};
}
private string? SmartDetectCategory(string fullPath)
{
var lowerPath = fullPath.ToLowerInvariant();
// Check for known keywords in path
if (lowerPath.Contains("car") || lowerPath.Contains("vehicle") || lowerPath.Contains("automobile"))
return "cars";
if (lowerPath.Contains("track") || lowerPath.Contains("circuit") || lowerPath.Contains("course"))
return "tracks";
if (lowerPath.Contains("audio") || lowerPath.Contains("sound") || lowerPath.Contains("music"))
return "audio";
if (lowerPath.Contains("texture") || lowerPath.Contains("material") || lowerPath.Contains("skin"))
return "textures";
if (lowerPath.Contains("ui") || lowerPath.Contains("hud") || lowerPath.Contains("menu"))
return "ui";
if (lowerPath.Contains("event") || lowerPath.Contains("race") || lowerPath.Contains("challenge"))
return "events";
if (lowerPath.Contains("dlc") || lowerPath.Contains("expansion") || lowerPath.Contains("addon"))
return "dlc";
if (lowerPath.Contains("update") || lowerPath.Contains("patch"))
return "updates";
// Fall back to first folder name
var pathParts = fullPath.Split('/', '\\');
if (pathParts.Length > 1)
return pathParts[0].ToLowerInvariant();
return null; // Will default to "base"
}
private string DetermineAssetType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
@@ -467,3 +565,23 @@ public class AssetStats
public long TotalSizeMB { get; set; }
public int TotalDownloads { get; set; }
}
// Manifest models for automatic ZIP metadata detection
public class AssetManifest
{
public string? Version { get; set; }
public string? GameVersion { get; set; }
public string? Description { get; set; }
public string? Author { get; set; }
public string? Category { get; set; }
public List<ManifestAsset>? Assets { get; set; }
}
public class ManifestAsset
{
public string File { get; set; } = string.Empty;
public string? Category { get; set; }
public string? Type { get; set; }
public bool Required { get; set; } = true;
public string? Description { get; set; }
}

View File

@@ -38,6 +38,9 @@ builder.Services.AddScoped<IDrmService, DrmService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<AssetExtractionService>();
// Add HttpClient for URL downloads
builder.Services.AddHttpClient();
// CORS for cross-origin requests
builder.Services.AddCors(options =>
{

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("RR3CommunityServer")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+15e842ce855b748cc97c43261eba408067128330")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f289cdfce9c28a229d8a00547a730c54530d68e9")]
[assembly: System.Reflection.AssemblyProductAttribute("RR3CommunityServer")]
[assembly: System.Reflection.AssemblyTitleAttribute("RR3CommunityServer")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
57cc4aab962a2ad12c842505535b2be3f034220ea319669b999c8dc52168130d
0da7d7611f502a833d49c872526fd4633f406323449f2304a3325dd643f82480

View File

@@ -1 +1 @@
{"documents":{"E:\\rr3\\RR3CommunityServer\\*":"https://raw.githubusercontent.com/ssfdre38/rr3-server/15e842ce855b748cc97c43261eba408067128330/*"}}
{"documents":{"E:\\rr3\\RR3CommunityServer\\*":"https://raw.githubusercontent.com/ssfdre38/rr3-server/f289cdfce9c28a229d8a00547a730c54530d68e9/*"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"gdYA/PLOQysRMD9wt3+IrqBqQw0g/GZFOcojepf8P6w=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["7Gcs8uTS1W2TjgmuuoBwaL/zy\u002B2wcKht3msEI7xtxEM=","UWedSjPPgrw4tts2Bk2ce0nYJfnBV9zMYOAjYg0PED8=","GecKXPxV0EAagvAtrRNTytwMtFCxZmgKm9sjLyEe8oI=","hf09a5b9bLKSNiG0xcVt876q/fUaHURnZieQui5FrSU=","hnhSRoeFpk3C6XWICUlX/lNip6TfbZWFYZv4weSCyrw=","fVR30KYkDSf6Wvsw9TujzlqruhwIMbw1wHxa1z/mksA=","bGtvAdvcs6Zz1qOTjdKz5gd/5jOpXDLvMjTZye3i/QI=","EoVh8vBcGohUnEMEoZuTXrpZ9uBDHT19VmDHc/D\u002Bm0I=","7gMXO5\u002Bhli7od21x4gC/qf3G6ddyyMyoSF6YFX9IaKg=","IdEjAFCVk3xZYjiEMESONot/jkvTj/gnwS5nnpGaIMc=","JVRe\u002Be2d47FunIfxVYRpqRFtljZ8gqrK3xMRy6TCd\u002BQ=","DQG0T8n9f5ohwv9akihU55D4/3WR7\u002BlDnvkdsAHHSgc=","VxDQNRQXYUU41o9SG4HrkKWR59FJIv8lmnwBolB/wE0=","x88k5Bg2fv\u002Bie1eIqFd4doOTQY0lwCNPv/5eJfhIK\u002Bw=","0Slg2/xnc5E9nXprYyph/57wQou\u002BhGSGgKchbo4aNOg="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"gdYA/PLOQysRMD9wt3+IrqBqQw0g/GZFOcojepf8P6w=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["7Gcs8uTS1W2TjgmuuoBwaL/zy\u002B2wcKht3msEI7xtxEM=","UWedSjPPgrw4tts2Bk2ce0nYJfnBV9zMYOAjYg0PED8=","GecKXPxV0EAagvAtrRNTytwMtFCxZmgKm9sjLyEe8oI=","S5l3\u002BBR9dKGtXWyJK2BwVbV5FvygplG8\u002Byh9AE1ZYRQ=","hnhSRoeFpk3C6XWICUlX/lNip6TfbZWFYZv4weSCyrw=","fVR30KYkDSf6Wvsw9TujzlqruhwIMbw1wHxa1z/mksA=","bGtvAdvcs6Zz1qOTjdKz5gd/5jOpXDLvMjTZye3i/QI=","EoVh8vBcGohUnEMEoZuTXrpZ9uBDHT19VmDHc/D\u002Bm0I=","7gMXO5\u002Bhli7od21x4gC/qf3G6ddyyMyoSF6YFX9IaKg=","IdEjAFCVk3xZYjiEMESONot/jkvTj/gnwS5nnpGaIMc=","JVRe\u002Be2d47FunIfxVYRpqRFtljZ8gqrK3xMRy6TCd\u002BQ=","DQG0T8n9f5ohwv9akihU55D4/3WR7\u002BlDnvkdsAHHSgc=","VxDQNRQXYUU41o9SG4HrkKWR59FJIv8lmnwBolB/wE0=","x88k5Bg2fv\u002Bie1eIqFd4doOTQY0lwCNPv/5eJfhIK\u002Bw=","0Slg2/xnc5E9nXprYyph/57wQou\u002BhGSGgKchbo4aNOg="],"CachedAssets":{},"CachedCopyCandidates":{}}