Compare commits

..

2 Commits

Author SHA1 Message Date
dd2c23000f 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>
2026-02-20 09:55:05 -08:00
f289cdfce9 Add ZIP bulk upload to asset manager
Features:
- Upload ZIP files with folder structure
- Automatic extraction and MD5/SHA256 calculation
- Preserve folder paths as EA CDN paths
- Auto-categorize based on file extensions
- Update existing assets automatically
- Bootstrap tabs for single/bulk upload UI
- Progress feedback (X new, Y updated)

Example: cars/porsche.dat → /cars/porsche.dat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-20 09:42:52 -08:00
24 changed files with 1693 additions and 73 deletions

View File

@@ -0,0 +1,154 @@
# RR3 Asset Manifest Specification
## Overview
When uploading ZIP files to the RR3 Community Server, you can include a `manifest.json` or `manifest.xml` file to automatically configure asset metadata, version, and categorization.
## File Format Options
### Option 1: JSON Format (Recommended)
Place `manifest.json` in the root of your ZIP file:
```json
{
"version": "9.3.0",
"gameVersion": "9.3.0",
"description": "Porsche Pack - 911 Models",
"author": "CommunityModder",
"category": "cars",
"assets": [
{
"file": "porsche/911_turbo.dat",
"category": "cars/porsche",
"type": "Model",
"required": true,
"description": "Porsche 911 Turbo model"
},
{
"file": "textures/911_turbo_paint.tex",
"category": "textures/cars",
"type": "Texture",
"required": false
}
]
}
```
### Option 2: XML Format
Place `manifest.xml` in the root of your ZIP file:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<AssetManifest>
<Version>9.3.0</Version>
<GameVersion>9.3.0</GameVersion>
<Description>Porsche Pack - 911 Models</Description>
<Author>CommunityModder</Author>
<Category>cars</Category>
<Assets>
<Asset>
<File>porsche/911_turbo.dat</File>
<Category>cars/porsche</Category>
<Type>Model</Type>
<Required>true</Required>
<Description>Porsche 911 Turbo model</Description>
</Asset>
<Asset>
<File>textures/911_turbo_paint.tex</File>
<Category>textures/cars</Category>
<Type>Texture</Type>
<Required>false</Required>
</Asset>
</Assets>
</AssetManifest>
```
## Field Descriptions
### Root Fields
- **version**: Asset pack version (e.g., "1.0.0")
- **gameVersion**: RR3 game version this pack is for (e.g., "9.3.0")
- **description**: Brief description of the asset pack
- **author**: Creator name (optional)
- **category**: Default category if not specified per-asset
### Asset Fields
- **file**: Relative path to file within ZIP (required)
- **category**: Asset category (overrides root category)
- **type**: Asset type (Data, Texture, Audio, Model, Config)
- **required**: Whether clients must download this (true/false)
- **description**: Brief description (optional)
## Game Version Format
- Use semantic versioning: `MAJOR.MINOR.PATCH`
- Examples: `9.3.0`, `9.3.1`, `10.0.0`
- **Compatibility**: Patch versions are compatible (9.3.x works with 9.3.0)
## Categories
Standard categories:
- `base` - Core game files
- `cars` - Car models and data
- `tracks` - Track models and data
- `audio` - Sound effects and music
- `textures` - Texture files
- `ui` - User interface elements
- `events` - Event configurations
- `dlc` - Downloadable content
- `updates` - Game updates
Subcategories allowed (e.g., `cars/porsche`, `tracks/silverstone`)
## Asset Types
- `Data` - Generic data files (.dat, .pak, .z)
- `Texture` - Texture files (.tex, .dds, .png)
- `Audio` - Audio files (.ogg, .mp3, .wav)
- `Model` - 3D model files (.nct, .obj)
- `Config` - Configuration files (.json, .xml)
## Automatic Detection Fallback
If no manifest file is provided, the server uses smart detection:
1. Searches folder names for keywords (cars, tracks, audio, etc.)
2. Preserves folder structure as subcategories
3. Falls back to first folder name if no keywords match
4. Version defaults to manual selection or "unknown"
## Example ZIP Structure
```
my-asset-pack.zip
├── manifest.json # Metadata file
├── cars/
│ ├── porsche/
│ │ ├── 911_turbo.dat
│ │ └── 911_gt3.dat
│ └── ferrari/
│ └── 488_gtb.dat
└── textures/
└── cars/
└── paint_textures.pak
```
## Upload Behavior
1. Server extracts ZIP to temp location
2. Searches for `manifest.json` or `manifest.xml` in root
3. If found: Uses metadata from manifest
4. If not found: Uses smart folder detection
5. Processes each file according to configuration
6. Calculates MD5/SHA256 hashes automatically
7. Stores in database with version + category info
## Version Compatibility
When a game client requests assets:
- Client sends version: `9.3.1`
- Server returns assets for: `9.3.0`, `9.3.1` (patch-compatible)
- Server excludes: `9.2.x`, `9.4.x`, `10.x.x`
Major/minor versions must match exactly, patch versions are compatible within the same minor version.
## Best Practices
1. Always include a manifest file for large packs
2. Use semantic versioning for both pack and game version
3. Mark base game assets as `required: true`
4. Use descriptive categories and subcategories
5. Include author information for community tracking
6. Test with single-file upload before bulk ZIP upload
7. Use JSON format for better tool support

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

@@ -66,76 +66,228 @@
<div class="col-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">⬆️ Upload New Asset</h5>
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="single-tab" data-bs-toggle="tab" data-bs-target="#single" type="button" role="tab">
📄 Single File
</button>
</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 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>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="assetFile" class="form-label">Asset File</label>
<input type="file" class="form-control" id="assetFile" name="assetFile" required>
<small class="text-muted">Supported: .pak, .z, .dat, .nct, .json, .xml, images, audio</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="eaCdnPath" class="form-label">EA CDN Path</label>
<input type="text" class="form-control" id="eaCdnPath" name="eaCdnPath" placeholder="/rr3/assets/file.pak" required>
<small class="text-muted">Path format: /rr3/category/filename.ext</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="category" class="form-label">Category</label>
<select class="form-select" id="category" name="category" required>
<option value="">Select category...</option>
<option value="base">Base Assets</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>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="assetType" class="form-label">Asset Type</label>
<select class="form-select" id="assetType" name="assetType">
<option value="Data">Data File</option>
<option value="Texture">Texture</option>
<option value="Audio">Audio</option>
<option value="Model">3D Model</option>
<option value="Config">Configuration</option>
</select>
</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="isRequired" name="isRequired" checked>
<label class="form-check-label" for="isRequired">
Required Asset (mandatory download)
</label>
<div class="tab-content">
<!-- Single File Upload -->
<div class="tab-pane fade show active" id="single" role="tabpanel">
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="assetFile" class="form-label">Asset File</label>
<input type="file" class="form-control" id="assetFile" name="assetFile" required>
<small class="text-muted">Supported: .pak, .z, .dat, .nct, .json, .xml, images, audio</small>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="eaCdnPath" class="form-label">EA CDN Path</label>
<input type="text" class="form-control" id="eaCdnPath" name="eaCdnPath" placeholder="/rr3/assets/file.pak" required>
<small class="text-muted">Path format: /rr3/category/filename.ext</small>
</div>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="category" class="form-label">Category</label>
<select class="form-select" id="category" name="category" required>
<option value="">Select category...</option>
<option value="base">Base Assets</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>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="assetType" class="form-label">Asset Type</label>
<select class="form-select" id="assetType" name="assetType">
<option value="Data">Data File</option>
<option value="Texture">Texture</option>
<option value="Audio">Audio</option>
<option value="Model">3D Model</option>
<option value="Config">Configuration</option>
</select>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<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">
Required Asset
</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Asset
</button>
</form>
</div>
<!-- ZIP Bulk Upload -->
<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 • 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">Include manifest.json for auto-detection • Example: cars/porsche_911.dat → /cars/porsche_911.dat</small>
</div>
<div class="row">
<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>
<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>
</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="isRequiredZip" name="isRequired" checked>
<label class="form-check-label" for="isRequiredZip">
Mark as required
</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-success">
<i class="bi bi-file-zip"></i> Extract and Upload
</button>
</form>
</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>
<!-- 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>
<button type="submit" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Asset
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore;
using RR3CommunityServer.Data;
using RR3CommunityServer.Models;
using System.Security.Cryptography;
using System.IO.Compression;
using System.Text.Json;
namespace RR3CommunityServer.Pages;
@@ -45,6 +47,7 @@ public class AssetsModel : PageModel
string category,
string assetType,
bool isRequired,
string gameVersion,
string? description)
{
try
@@ -107,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!";
@@ -127,6 +131,7 @@ public class AssetsModel : PageModel
IsRequired = isRequired,
Description = description,
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
@@ -150,6 +155,236 @@ public class AssetsModel : PageModel
return Page();
}
public async Task<IActionResult> OnPostUploadZipAsync(
IFormFile zipFile,
string baseCategory,
string? gameVersion,
bool isRequired)
{
try
{
if (zipFile == null || zipFile.Length == 0)
{
Message = "No ZIP file selected.";
IsError = true;
await OnGetAsync();
return Page();
}
if (!zipFile.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
{
Message = "Please upload a ZIP file.";
IsError = true;
await OnGetAsync();
return Page();
}
// Ensure assets directory exists
if (!Directory.Exists(_assetsBasePath))
{
Directory.CreateDirectory(_assetsBasePath);
}
// Save ZIP to temp location
var tempZipPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".zip");
using (var stream = new FileStream(tempZipPath, FileMode.Create))
{
await zipFile.CopyToAsync(stream);
}
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))
{
foreach (var entry in archive.Entries)
{
try
{
// 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;
// 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 = manifestAsset?.Category ?? baseCategory;
// Auto-detect if category is "auto"
if (category == "auto")
{
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
var categoryPath = Path.Combine(_assetsBasePath, category);
if (!Directory.Exists(categoryPath))
{
Directory.CreateDirectory(categoryPath);
}
// Extract file
var fileName = entry.Name;
var localPath = Path.Combine(categoryPath, fileName);
// Extract to disk
entry.ExtractToFile(localPath, overwrite: true);
// Calculate hashes
var md5Hash = await CalculateMd5Async(localPath);
var sha256Hash = await CalculateSha256Async(localPath);
var fileInfo = new FileInfo(localPath);
// 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
.FirstOrDefaultAsync(a => a.EaCdnPath == eaCdnPath);
if (existingAsset != null)
{
// Update existing
existingAsset.FileName = fileName;
existingAsset.LocalPath = localPath;
existingAsset.FileSize = fileInfo.Length;
existingAsset.Md5Hash = md5Hash;
existingAsset.FileSha256 = sha256Hash;
existingAsset.Category = category;
existingAsset.AssetType = assetType;
existingAsset.IsRequired = required;
existingAsset.Description = description;
existingAsset.ContentType = GetContentType(fileName);
existingAsset.Version = gameVersion;
existingAsset.UploadedAt = DateTime.UtcNow;
skippedCount++;
}
else
{
// Create new asset
var asset = new GameAsset
{
FileName = fileName,
EaCdnPath = eaCdnPath,
LocalPath = localPath,
FileSize = fileInfo.Length,
Md5Hash = md5Hash,
FileSha256 = sha256Hash,
Category = category,
AssetType = assetType,
IsRequired = required,
Description = description ?? $"Extracted from {zipFile.FileName}",
ContentType = GetContentType(fileName),
Version = gameVersion,
UploadedAt = DateTime.UtcNow,
DownloadedAt = DateTime.UtcNow
};
_context.GameAssets.Add(asset);
extractedCount++;
}
}
catch (Exception ex)
{
errors.Add($"{entry.FullName}: {ex.Message}");
_logger.LogError(ex, "Error extracting file from ZIP: {FileName}", entry.FullName);
}
}
}
// Save changes
await _context.SaveChangesAsync();
// Clean up temp ZIP
if (System.IO.File.Exists(tempZipPath))
{
System.IO.File.Delete(tempZipPath);
}
// Build message
if (errors.Any())
{
Message = $"ZIP processed: {extractedCount} new, {skippedCount} updated. Errors: {errors.Count}";
IsError = true;
}
else
{
Message = $"ZIP extracted successfully! {extractedCount} new files, {skippedCount} updated.";
}
_logger.LogInformation("ZIP uploaded: {FileName} -> {ExtractedCount} files", zipFile.FileName, extractedCount);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing ZIP file");
Message = $"Error processing ZIP: {ex.Message}";
IsError = true;
}
await OnGetAsync();
return Page();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
try
@@ -278,6 +513,49 @@ public class AssetsModel : PageModel
_ => "application/octet-stream"
};
}
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();
return extension switch
{
".png" or ".jpg" or ".jpeg" or ".pvr" or ".atlas" => "Texture",
".mp3" or ".ogg" or ".wav" => "Audio",
".json" or ".xml" => "Config",
".pak" or ".dat" or ".nct" => "Data",
_ => "Data"
};
}
}
public class AssetStats
@@ -287,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+f1d0d43cb70abf0fc44598a01a6250f3b7b73922")]
[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 @@
42ee391f7017f761577d9436f4160570943e6c116049654eb80a0c5412651e8a
0da7d7611f502a833d49c872526fd4633f406323449f2304a3325dd643f82480

View File

@@ -1 +1 @@
{"documents":{"E:\\rr3\\RR3CommunityServer\\*":"https://raw.githubusercontent.com/ssfdre38/rr3-server/f1d0d43cb70abf0fc44598a01a6250f3b7b73922/*"}}
{"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=","AD8WKv0o3OeySN/Mlu5s1a4y3Dt/ik0jFKKHCrGjFtA=","hnhSRoeFpk3C6XWICUlX/lNip6TfbZWFYZv4weSCyrw=","fVR30KYkDSf6Wvsw9TujzlqruhwIMbw1wHxa1z/mksA=","VpFNnyDFqynPhhZPcyqeWcncA9QmAv\u002BG3ez5PxfzaTQ=","EoVh8vBcGohUnEMEoZuTXrpZ9uBDHT19VmDHc/D\u002Bm0I=","eB7z8UswjcYO/RErEzjxxHwVLVba/7iPOUH17NS53Fw=","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":{}}

Binary file not shown.