Story
This commit is contained in:
32504
SVSim.Bootstrap/Data/story/chapters.json
Normal file
32504
SVSim.Bootstrap/Data/story/chapters.json
Normal file
File diff suppressed because it is too large
Load Diff
377
SVSim.Bootstrap/Data/story/sections.json
Normal file
377
SVSim.Bootstrap/Data/story/sections.json
Normal file
@@ -0,0 +1,377 @@
|
||||
[
|
||||
{
|
||||
"id": 0,
|
||||
"world_id": 1,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 1,
|
||||
"all_story_order_id": 6,
|
||||
"name_text_key": "story_section_00",
|
||||
"image_name": "btn_story_tutorial",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 1,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"world_id": 2,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 2,
|
||||
"all_story_order_id": 7,
|
||||
"name_text_key": "story_section_01",
|
||||
"image_name": "btn_story_section_01",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 1,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"world_id": 2,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 3,
|
||||
"all_story_order_id": 8,
|
||||
"name_text_key": "story_section_02",
|
||||
"image_name": "btn_story_section_02",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 1,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"world_id": 3,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 4,
|
||||
"all_story_order_id": 9,
|
||||
"name_text_key": "story_section_03",
|
||||
"image_name": "btn_story_section_03",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 2,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"world_id": 3,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 5,
|
||||
"all_story_order_id": 10,
|
||||
"name_text_key": "story_section_04",
|
||||
"image_name": "btn_story_section_04",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 3,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"world_id": 4,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 6,
|
||||
"all_story_order_id": 11,
|
||||
"name_text_key": "story_section_05",
|
||||
"image_name": "btn_story_section_05",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 4,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"world_id": 4,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 7,
|
||||
"all_story_order_id": 12,
|
||||
"name_text_key": "story_section_06",
|
||||
"image_name": "btn_story_section_06",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 4,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"world_id": 4,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 8,
|
||||
"all_story_order_id": 13,
|
||||
"name_text_key": "story_section_07",
|
||||
"image_name": "btn_story_section_07",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 7,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"world_id": 4,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 9,
|
||||
"all_story_order_id": 14,
|
||||
"name_text_key": "story_section_08",
|
||||
"image_name": "btn_story_section_08",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 7,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"world_id": 4,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 10,
|
||||
"all_story_order_id": 15,
|
||||
"name_text_key": "story_section_09",
|
||||
"image_name": "btn_story_section_09",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 9,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"world_id": 5,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 11,
|
||||
"all_story_order_id": 16,
|
||||
"name_text_key": "story_section_10",
|
||||
"image_name": "btn_story_section_10",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 10,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"world_id": 5,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 12,
|
||||
"all_story_order_id": 17,
|
||||
"name_text_key": "story_section_11",
|
||||
"image_name": "btn_story_section_11",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 10,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"world_id": 5,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 13,
|
||||
"all_story_order_id": 18,
|
||||
"name_text_key": "story_section_12",
|
||||
"image_name": "btn_story_section_12",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 12,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"world_id": 5,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 14,
|
||||
"all_story_order_id": 19,
|
||||
"name_text_key": "story_section_13",
|
||||
"image_name": "btn_story_section_13",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 12,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"world_id": 5,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 15,
|
||||
"all_story_order_id": 20,
|
||||
"name_text_key": "story_section_14",
|
||||
"image_name": "btn_story_section_14",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 10,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"world_id": 6,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 16,
|
||||
"all_story_order_id": 21,
|
||||
"name_text_key": "story_section_15",
|
||||
"image_name": "btn_story_section_15",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 15,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"world_id": 6,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 17,
|
||||
"all_story_order_id": 22,
|
||||
"name_text_key": "story_section_16",
|
||||
"image_name": "btn_story_section_16",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 15,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"world_id": 7,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 18,
|
||||
"all_story_order_id": 23,
|
||||
"name_text_key": "story_section_17",
|
||||
"image_name": "btn_story_section_17",
|
||||
"is_leader_select": true,
|
||||
"back_ground_id": 17,
|
||||
"chapter_select_type": 2,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"world_id": 7,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 19,
|
||||
"all_story_order_id": 24,
|
||||
"name_text_key": "story_section_18",
|
||||
"image_name": "btn_story_section_18",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 17,
|
||||
"chapter_select_type": 2,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"world_id": 8,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 20,
|
||||
"all_story_order_id": 25,
|
||||
"name_text_key": "story_section_19",
|
||||
"image_name": "btn_story_section_19",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 19,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"world_id": 8,
|
||||
"story_api_type": "Main",
|
||||
"order_id": 21,
|
||||
"all_story_order_id": 26,
|
||||
"name_text_key": "story_section_20",
|
||||
"image_name": "btn_story_section_20",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 2,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 1,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 9001,
|
||||
"world_id": 1,
|
||||
"story_api_type": "Limited",
|
||||
"order_id": 1,
|
||||
"all_story_order_id": 2,
|
||||
"name_text_key": "story_section_9001",
|
||||
"image_name": "btn_story_section_9001",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 2,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 9002,
|
||||
"world_id": 1,
|
||||
"story_api_type": "Limited",
|
||||
"order_id": 2,
|
||||
"all_story_order_id": 3,
|
||||
"name_text_key": "story_section_9002",
|
||||
"image_name": "btn_story_section_9002",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 9,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 9003,
|
||||
"world_id": 1,
|
||||
"story_api_type": "Limited",
|
||||
"order_id": 3,
|
||||
"all_story_order_id": 4,
|
||||
"name_text_key": "story_section_9003",
|
||||
"image_name": "btn_story_section_9003",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 1,
|
||||
"chapter_select_type": 1,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
},
|
||||
{
|
||||
"id": 9005,
|
||||
"world_id": 1,
|
||||
"story_api_type": "Limited",
|
||||
"order_id": 5,
|
||||
"all_story_order_id": 5,
|
||||
"name_text_key": "story_section_9005",
|
||||
"image_name": "btn_story_section_9005",
|
||||
"is_leader_select": false,
|
||||
"back_ground_id": 9005,
|
||||
"chapter_select_type": 2,
|
||||
"story_type_overwrite": 2,
|
||||
"is_under_maintenance": false,
|
||||
"is_play_another_end_appearance_animation": false
|
||||
}
|
||||
]
|
||||
758
SVSim.Bootstrap/Data/story/special-battle-settings.json
Normal file
758
SVSim.Bootstrap/Data/story/special-battle-settings.json
Normal file
File diff suppressed because one or more lines are too long
50
SVSim.Bootstrap/Data/story/worlds.json
Normal file
50
SVSim.Bootstrap/Data/story/worlds.json
Normal file
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title_text_key": "story_select_world_title_01",
|
||||
"panel_image_name": "story_select_world_panel_01",
|
||||
"ribbon_text": ""
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title_text_key": "story_select_world_title_02",
|
||||
"panel_image_name": "story_select_world_panel_02",
|
||||
"ribbon_text": ""
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title_text_key": "story_select_world_title_03",
|
||||
"panel_image_name": "story_select_world_panel_03",
|
||||
"ribbon_text": ""
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title_text_key": "story_select_world_title_04",
|
||||
"panel_image_name": "story_select_world_panel_04",
|
||||
"ribbon_text": ""
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title_text_key": "story_select_world_title_05",
|
||||
"panel_image_name": "story_select_world_panel_05",
|
||||
"ribbon_text": ""
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title_text_key": "story_select_world_title_06",
|
||||
"panel_image_name": "story_select_world_panel_06",
|
||||
"ribbon_text": ""
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title_text_key": "story_select_world_title_07",
|
||||
"panel_image_name": "story_select_world_panel_07",
|
||||
"ribbon_text": ""
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title_text_key": "story_select_world_title_08",
|
||||
"panel_image_name": "story_select_world_panel_08-01",
|
||||
"ribbon_text": ""
|
||||
}
|
||||
]
|
||||
276
SVSim.Bootstrap/Importers/StoryImporter.cs
Normal file
276
SVSim.Bootstrap/Importers/StoryImporter.cs
Normal file
@@ -0,0 +1,276 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Entities.Story;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
/// <summary>
|
||||
/// Reads worlds.json, sections.json, chapters.json, special-battle-settings.json from a story
|
||||
/// data directory and upserts the corresponding entities. Idempotent. FK ordering: SBS → Worlds
|
||||
/// → Sections → Chapters (with owned collections cascading).
|
||||
/// </summary>
|
||||
public class StoryImporter
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString,
|
||||
};
|
||||
|
||||
public async Task ImportAsync(SVSimDbContext context, string storyDataDir)
|
||||
{
|
||||
string worldsPath = Path.Combine(storyDataDir, "importer-worlds.json");
|
||||
string sectionsPath = Path.Combine(storyDataDir, "importer-sections.json");
|
||||
string chaptersPath = Path.Combine(storyDataDir, "importer-chapters.json");
|
||||
string sbsPath = Path.Combine(storyDataDir, "importer-sbs.json");
|
||||
|
||||
// Fallback to production filenames when fixture-prefixed names aren't present.
|
||||
if (!File.Exists(worldsPath)) worldsPath = Path.Combine(storyDataDir, "worlds.json");
|
||||
if (!File.Exists(sectionsPath)) sectionsPath = Path.Combine(storyDataDir, "sections.json");
|
||||
if (!File.Exists(chaptersPath)) chaptersPath = Path.Combine(storyDataDir, "chapters.json");
|
||||
if (!File.Exists(sbsPath)) sbsPath = Path.Combine(storyDataDir, "special-battle-settings.json");
|
||||
|
||||
if (!File.Exists(chaptersPath))
|
||||
{
|
||||
Console.Error.WriteLine($"[Story] chapters.json not found at {chaptersPath}; skipping story import.");
|
||||
return;
|
||||
}
|
||||
|
||||
var inputSbs = await ReadOrEmptyAsync<List<SbsInput>>(sbsPath);
|
||||
var inputWorlds = await ReadOrEmptyAsync<List<WorldInput>>(worldsPath);
|
||||
var inputSections = await ReadOrEmptyAsync<List<SectionInput>>(sectionsPath);
|
||||
var inputChapters = await ReadOrEmptyAsync<List<ChapterInput>>(chaptersPath);
|
||||
|
||||
Console.WriteLine($"[Story] Parsed {inputWorlds.Count} worlds, {inputSections.Count} sections, " +
|
||||
$"{inputChapters.Count} chapters, {inputSbs.Count} sbs payloads.");
|
||||
|
||||
int sbsCreated = 0, sbsUpdated = 0;
|
||||
var existingSbs = await context.SpecialBattleSettings.ToDictionaryAsync(x => x.Id);
|
||||
foreach (var s in inputSbs)
|
||||
{
|
||||
if (existingSbs.TryGetValue(s.Id, out var row))
|
||||
{
|
||||
Apply(row, s); sbsUpdated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.SpecialBattleSettings.Add(ToEntity(s)); sbsCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
int wCreated = 0, wUpdated = 0;
|
||||
var existingWorlds = await context.StoryWorlds.ToDictionaryAsync(x => x.Id);
|
||||
foreach (var w in inputWorlds)
|
||||
{
|
||||
if (existingWorlds.TryGetValue(w.Id, out var row))
|
||||
{
|
||||
row.TitleTextKey = w.TitleTextKey; row.PanelImageName = w.PanelImageName; row.RibbonText = w.RibbonText;
|
||||
wUpdated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
context.StoryWorlds.Add(new StoryWorld {
|
||||
Id = w.Id, TitleTextKey = w.TitleTextKey,
|
||||
PanelImageName = w.PanelImageName, RibbonText = w.RibbonText });
|
||||
wCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
int secCreated = 0, secUpdated = 0;
|
||||
var existingSections = await context.StorySections.ToDictionaryAsync(x => x.Id);
|
||||
foreach (var s in inputSections)
|
||||
{
|
||||
if (existingSections.TryGetValue(s.Id, out var row)) { Apply(row, s); secUpdated++; }
|
||||
else { context.StorySections.Add(ToEntity(s)); secCreated++; }
|
||||
}
|
||||
|
||||
int chCreated = 0, chUpdated = 0;
|
||||
var existingChapters = await context.StoryChapters
|
||||
.Include(c => c.BattleSettings).Include(c => c.Rewards).Include(c => c.SubChapters)
|
||||
.ToDictionaryAsync(x => x.StoryId);
|
||||
foreach (var c in inputChapters)
|
||||
{
|
||||
if (existingChapters.TryGetValue(c.StoryId, out var row)) { Apply(row, c); chUpdated++; }
|
||||
else { context.StoryChapters.Add(ToEntity(c)); chCreated++; }
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Story] Saving: worlds +{wCreated}/~{wUpdated}, sections +{secCreated}/~{secUpdated}, " +
|
||||
$"chapters +{chCreated}/~{chUpdated}, sbs +{sbsCreated}/~{sbsUpdated}...");
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine("[Story] Done.");
|
||||
}
|
||||
|
||||
private static async Task<T> ReadOrEmptyAsync<T>(string path) where T : new()
|
||||
{
|
||||
if (!File.Exists(path)) return new T();
|
||||
await using var fs = File.OpenRead(path);
|
||||
return await JsonSerializer.DeserializeAsync<T>(fs, JsonOpts) ?? new T();
|
||||
}
|
||||
|
||||
// --- mapping helpers ---
|
||||
|
||||
private static SpecialBattleSetting ToEntity(SbsInput s) => Apply(new SpecialBattleSetting { Id = s.Id }, s);
|
||||
private static SpecialBattleSetting Apply(SpecialBattleSetting row, SbsInput s)
|
||||
{
|
||||
row.PlayerFirstTurn = s.PlayerFirstTurn;
|
||||
row.PlayerStartPp = s.PlayerStartPp; row.EnemyStartPp = s.EnemyStartPp;
|
||||
row.PlayerStartLife = s.PlayerStartLife; row.EnemyStartLife = s.EnemyStartLife;
|
||||
row.PlayerAttachSkill = s.PlayerAttachSkill ?? ""; row.EnemyAttachSkill = s.EnemyAttachSkill ?? "";
|
||||
row.IdOverrideInBattleLog = s.IdOverrideInBattleLog ?? "";
|
||||
row.BanishEffectOverride = s.BanishEffectOverride ?? "";
|
||||
row.TokenDrawEffectOverride = s.TokenDrawEffectOverride ?? "";
|
||||
row.SpecialTokenDrawEffectOverride = s.SpecialTokenDrawEffectOverride ?? "";
|
||||
row.ResultSkip = s.ResultSkip;
|
||||
row.VsEffectOverride = s.VsEffectOverride;
|
||||
row.ClassDestroyEffectOverride = s.ClassDestroyEffectOverride;
|
||||
row.Note = s.Note;
|
||||
return row;
|
||||
}
|
||||
|
||||
private static StorySection ToEntity(SectionInput s) => Apply(new StorySection { Id = s.Id }, s);
|
||||
private static StorySection Apply(StorySection row, SectionInput s)
|
||||
{
|
||||
row.WorldId = s.WorldId;
|
||||
row.StoryApiType = Enum.Parse<StoryApiType>(s.StoryApiType ?? "Main");
|
||||
row.OrderId = s.OrderId; row.AllStoryOrderId = s.AllStoryOrderId;
|
||||
row.NameTextKey = s.NameTextKey ?? ""; row.ImageName = s.ImageName ?? "";
|
||||
row.IsLeaderSelect = s.IsLeaderSelect; row.BackGroundId = s.BackGroundId;
|
||||
row.ChapterSelectType = s.ChapterSelectType; row.StoryTypeOverwrite = s.StoryTypeOverwrite;
|
||||
row.IsUnderMaintenance = s.IsUnderMaintenance;
|
||||
row.IsPlayAnotherEndAppearanceAnimation = s.IsPlayAnotherEndAppearanceAnimation;
|
||||
return row;
|
||||
}
|
||||
|
||||
private static StoryChapter ToEntity(ChapterInput c) => Apply(new StoryChapter { StoryId = c.StoryId }, c);
|
||||
private static StoryChapter Apply(StoryChapter row, ChapterInput c)
|
||||
{
|
||||
row.SectionId = c.SectionId; row.CharaId = c.CharaId;
|
||||
row.ChapterId = c.ChapterId ?? ""; row.NextChapterId = c.NextChapterId ?? "";
|
||||
row.RequiredChapterId = c.RequiredChapterId;
|
||||
row.SelectionDisplayPosition = c.SelectionDisplayPosition;
|
||||
row.SelectionTextId = c.SelectionTextId;
|
||||
row.ShowCoordinate = c.ShowCoordinate;
|
||||
row.XCoordinate = (decimal)c.XCoordinate; row.YCoordinate = (decimal)c.YCoordinate;
|
||||
row.IsCameraMovable = c.IsCameraMovable; row.ShowSubtitles = c.ShowSubtitles;
|
||||
row.BattleExists = c.BattleExists;
|
||||
row.EnemyCharaId = c.EnemyCharaId; row.EnemyClass = c.EnemyClass; row.EnemyAiId = c.EnemyAiId;
|
||||
row.BgFileName = c.BgFileName ?? "";
|
||||
row.ChapterEffectPath = c.ChapterEffectPath; row.ChapterClearTextId = c.ChapterClearTextId;
|
||||
row.Battle3dFieldId = c.Battle3dFieldId; row.BgmId = c.BgmId ?? "";
|
||||
row.SpecialBattleSettingId = c.SpecialBattleSettingId;
|
||||
row.ReleasePoint = c.ReleasePoint; row.IsMaintenanceChapter = c.IsMaintenanceChapter;
|
||||
row.IsPlayAnotherEndAppearanceAnimation = c.IsPlayAnotherEndAppearanceAnimation;
|
||||
row.IsReleasedAnotherEnd = c.IsReleasedAnotherEnd;
|
||||
row.IsSkipEnabled = c.IsSkipEnabled;
|
||||
|
||||
// Owned collections: clear + replace, EF tracks the deletes.
|
||||
row.BattleSettings.Clear();
|
||||
foreach (var b in c.BattleSettings ?? new())
|
||||
row.BattleSettings.Add(new StoryChapterBattleSetting
|
||||
{
|
||||
DeckClassId = b.DeckClassId,
|
||||
PlayerEmotionOverride = b.PlayerEmotionOverride,
|
||||
EnemyEmotionOverride = b.EnemyEmotionOverride,
|
||||
SkinIdOverride = b.SkinIdOverride,
|
||||
Battle3dFieldIdOverride = b.Battle3dFieldIdOverride,
|
||||
BgmIdOverride = b.BgmIdOverride,
|
||||
DeckSkinIdOverride = b.DeckSkinIdOverride,
|
||||
});
|
||||
|
||||
row.Rewards.Clear();
|
||||
foreach (var r in c.StoryReward ?? new())
|
||||
row.Rewards.Add(new StoryChapterReward
|
||||
{
|
||||
RewardType = r.RewardType,
|
||||
RewardDetailId = r.RewardDetailId,
|
||||
RewardNumber = r.RewardNumber,
|
||||
});
|
||||
|
||||
row.SubChapters.Clear();
|
||||
foreach (var sc in c.SubChapters ?? new())
|
||||
row.SubChapters.Add(new StorySubChapter
|
||||
{
|
||||
SubChapterId = sc.SubChapterId,
|
||||
SubChapterStoryId = sc.SubChapterStoryId,
|
||||
IsMaintenanceChapter = sc.IsMaintenanceChapter,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
// --- input shapes (snake_case via JsonOpts) ---
|
||||
|
||||
private class SbsInput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int PlayerFirstTurn { get; set; }
|
||||
public int PlayerStartPp { get; set; } public int EnemyStartPp { get; set; }
|
||||
public int PlayerStartLife { get; set; } public int EnemyStartLife { get; set; }
|
||||
public string? PlayerAttachSkill { get; set; } public string? EnemyAttachSkill { get; set; }
|
||||
public string? IdOverrideInBattleLog { get; set; }
|
||||
public string? BanishEffectOverride { get; set; }
|
||||
public string? TokenDrawEffectOverride { get; set; }
|
||||
public string? SpecialTokenDrawEffectOverride { get; set; }
|
||||
public int ResultSkip { get; set; } public int VsEffectOverride { get; set; }
|
||||
public int ClassDestroyEffectOverride { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
private class WorldInput
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string TitleTextKey { get; set; } = "";
|
||||
public string PanelImageName { get; set; } = "";
|
||||
public string RibbonText { get; set; } = "";
|
||||
}
|
||||
private class SectionInput
|
||||
{
|
||||
public int Id { get; set; } public int? WorldId { get; set; }
|
||||
public string? StoryApiType { get; set; }
|
||||
public int OrderId { get; set; } public int AllStoryOrderId { get; set; }
|
||||
public string? NameTextKey { get; set; } public string? ImageName { get; set; }
|
||||
public bool IsLeaderSelect { get; set; } public int BackGroundId { get; set; }
|
||||
public int ChapterSelectType { get; set; } public int StoryTypeOverwrite { get; set; }
|
||||
public bool IsUnderMaintenance { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
}
|
||||
private class ChapterInput
|
||||
{
|
||||
public int StoryId { get; set; } public int SectionId { get; set; } public int CharaId { get; set; }
|
||||
public string? ChapterId { get; set; } public string? NextChapterId { get; set; }
|
||||
public string? RequiredChapterId { get; set; }
|
||||
public string? SelectionDisplayPosition { get; set; } public string? SelectionTextId { get; set; }
|
||||
public int ShowCoordinate { get; set; }
|
||||
public double XCoordinate { get; set; } public double YCoordinate { get; set; }
|
||||
public int IsCameraMovable { get; set; } public int ShowSubtitles { get; set; }
|
||||
public bool BattleExists { get; set; } public int EnemyCharaId { get; set; }
|
||||
public int EnemyClass { get; set; } public int EnemyAiId { get; set; }
|
||||
public string? BgFileName { get; set; } public string? ChapterEffectPath { get; set; }
|
||||
public string? ChapterClearTextId { get; set; }
|
||||
[JsonPropertyName("battle3dfield_id")]
|
||||
public int Battle3dFieldId { get; set; }
|
||||
public string? BgmId { get; set; }
|
||||
public int? SpecialBattleSettingId { get; set; }
|
||||
public int ReleasePoint { get; set; } public bool IsMaintenanceChapter { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
public bool IsReleasedAnotherEnd { get; set; } public bool IsSkipEnabled { get; set; }
|
||||
public List<BattleSettingInput>? BattleSettings { get; set; }
|
||||
public List<RewardInput>? StoryReward { get; set; }
|
||||
public List<SubChapterInput>? SubChapters { get; set; }
|
||||
}
|
||||
private class BattleSettingInput {
|
||||
public int DeckClassId { get; set; }
|
||||
public int PlayerEmotionOverride { get; set; } public int EnemyEmotionOverride { get; set; }
|
||||
public int SkinIdOverride { get; set; }
|
||||
[JsonPropertyName("battle3dfield_id_override")]
|
||||
public int Battle3dFieldIdOverride { get; set; }
|
||||
public int BgmIdOverride { get; set; } public int DeckSkinIdOverride { get; set; }
|
||||
}
|
||||
private class RewardInput {
|
||||
public int RewardType { get; set; } public long RewardDetailId { get; set; } public int RewardNumber { get; set; }
|
||||
}
|
||||
private class SubChapterInput {
|
||||
public int SubChapterId { get; set; } public int SubChapterStoryId { get; set; }
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public static class Program
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (opts.SkipReference && opts.SkipCards && opts.SkipGlobals)
|
||||
if (opts.SkipReference && opts.SkipCards && opts.SkipGlobals && opts.SkipStory)
|
||||
{
|
||||
Console.Error.WriteLine("All --skip-* flags set; nothing to do.");
|
||||
return 1;
|
||||
@@ -82,6 +82,15 @@ public static class Program
|
||||
Console.WriteLine("[Bootstrap] --skip-globals set; skipping globals import.");
|
||||
}
|
||||
|
||||
if (!opts.SkipStory)
|
||||
{
|
||||
await new StoryImporter().ImportAsync(context, opts.StoryDataDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[Bootstrap] --skip-story set; skipping story import.");
|
||||
}
|
||||
|
||||
Console.WriteLine("[Bootstrap] Complete.");
|
||||
return 0;
|
||||
}
|
||||
@@ -95,6 +104,8 @@ public static class Program
|
||||
bool skipReference = false;
|
||||
bool skipCards = false;
|
||||
bool skipGlobals = false;
|
||||
bool skipStory = false;
|
||||
string? storyDataDir = null;
|
||||
string? positionalCards = null;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
@@ -109,6 +120,8 @@ public static class Program
|
||||
case "--skip-reference": skipReference = true; break;
|
||||
case "--skip-cards": skipCards = true; break;
|
||||
case "--skip-globals": skipGlobals = true; break;
|
||||
case "--skip-story": skipStory = true; break;
|
||||
case "--story-data-dir": storyDataDir = NextArg(args, ref i); break;
|
||||
default:
|
||||
// Back-compat: legacy positional form `svsim-card-import <cards.json> [connection]`.
|
||||
if (positionalCards is null && !a.StartsWith('-')) positionalCards = a;
|
||||
@@ -129,13 +142,16 @@ public static class Program
|
||||
string cardsFile = cards ?? positionalCards ?? shippedCardsFile;
|
||||
string capturesDir = captures ?? shippedCaptures;
|
||||
string refDir = referenceDataDir ?? shippedDataDir;
|
||||
string shippedStoryDir = Path.Combine(shippedDataDir, "story");
|
||||
string storyDir = storyDataDir ?? shippedStoryDir;
|
||||
|
||||
string connStr = connection
|
||||
?? Environment.GetEnvironmentVariable("NPGSQL_CONNECTION")
|
||||
?? DefaultConnectionString;
|
||||
|
||||
return new BootstrapOptions(
|
||||
cardsFile, capturesDir, refDir, connStr, skipReference, skipCards, skipGlobals);
|
||||
cardsFile, capturesDir, refDir, connStr, skipReference, skipCards, skipGlobals,
|
||||
skipStory, storyDir);
|
||||
}
|
||||
|
||||
private static string NextArg(string[] args, ref int i)
|
||||
@@ -165,6 +181,8 @@ public static class Program
|
||||
" --skip-reference Skip reference-data import (classes, sleeves, ranks, ...)\n" +
|
||||
" --skip-cards Skip card + card-cosmetic-reward import\n" +
|
||||
" --skip-globals Skip prod-captured globals import\n" +
|
||||
" --story-data-dir <dir> Override story data directory (default: shipped Data/story)\n" +
|
||||
" --skip-story Skip story import (worlds/sections/chapters/sbs)\n" +
|
||||
"\n" +
|
||||
"Back-compat: `svsim-bootstrap <cards.json> [connection]` still works (positional).");
|
||||
}
|
||||
@@ -176,5 +194,7 @@ public static class Program
|
||||
string ConnectionString,
|
||||
bool SkipReference,
|
||||
bool SkipCards,
|
||||
bool SkipGlobals);
|
||||
bool SkipGlobals,
|
||||
bool SkipStory,
|
||||
string StoryDataDir);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
<Content Include="Data\cards.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Data\story\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
27
SVSim.Database/Entities/Story/SpecialBattleSetting.cs
Normal file
27
SVSim.Database/Entities/Story/SpecialBattleSetting.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
public class SpecialBattleSetting
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public int Id { get; set; }
|
||||
|
||||
public int PlayerFirstTurn { get; set; }
|
||||
public int PlayerStartPp { get; set; }
|
||||
public int EnemyStartPp { get; set; }
|
||||
public int PlayerStartLife { get; set; }
|
||||
public int EnemyStartLife { get; set; }
|
||||
public string PlayerAttachSkill { get; set; } = string.Empty;
|
||||
public string EnemyAttachSkill { get; set; } = string.Empty;
|
||||
public string IdOverrideInBattleLog { get; set; } = string.Empty;
|
||||
public string BanishEffectOverride { get; set; } = string.Empty;
|
||||
public string TokenDrawEffectOverride { get; set; } = string.Empty;
|
||||
public string SpecialTokenDrawEffectOverride { get; set; } = string.Empty;
|
||||
public int ResultSkip { get; set; }
|
||||
public int VsEffectOverride { get; set; }
|
||||
public int ClassDestroyEffectOverride { get; set; }
|
||||
public string? Note { get; set; }
|
||||
}
|
||||
10
SVSim.Database/Entities/Story/StoryApiType.cs
Normal file
10
SVSim.Database/Entities/Story/StoryApiType.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
public enum StoryApiType
|
||||
{
|
||||
None = 0,
|
||||
Main = 1,
|
||||
Limited = 2,
|
||||
Event = 3,
|
||||
AllStory = 4,
|
||||
}
|
||||
51
SVSim.Database/Entities/Story/StoryChapter.cs
Normal file
51
SVSim.Database/Entities/Story/StoryChapter.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
public class StoryChapter
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public int StoryId { get; set; }
|
||||
|
||||
public int SectionId { get; set; }
|
||||
public StorySection? Section { get; set; }
|
||||
|
||||
public int CharaId { get; set; }
|
||||
public string ChapterId { get; set; } = string.Empty;
|
||||
public string NextChapterId { get; set; } = string.Empty;
|
||||
public string? RequiredChapterId { get; set; }
|
||||
|
||||
public string? SelectionDisplayPosition { get; set; }
|
||||
public string? SelectionTextId { get; set; }
|
||||
public decimal XCoordinate { get; set; }
|
||||
public decimal YCoordinate { get; set; }
|
||||
public int ShowCoordinate { get; set; }
|
||||
public int IsCameraMovable { get; set; }
|
||||
public int ShowSubtitles { get; set; }
|
||||
|
||||
public bool BattleExists { get; set; }
|
||||
public int EnemyCharaId { get; set; }
|
||||
public int EnemyClass { get; set; }
|
||||
public int EnemyAiId { get; set; }
|
||||
public string BgFileName { get; set; } = string.Empty;
|
||||
public string? ChapterEffectPath { get; set; }
|
||||
public string? ChapterClearTextId { get; set; }
|
||||
public int Battle3dFieldId { get; set; }
|
||||
public string BgmId { get; set; } = string.Empty;
|
||||
|
||||
public int? SpecialBattleSettingId { get; set; }
|
||||
public SpecialBattleSetting? SpecialBattleSetting { get; set; }
|
||||
|
||||
public int ReleasePoint { get; set; }
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
public bool IsReleasedAnotherEnd { get; set; }
|
||||
public bool IsSkipEnabled { get; set; }
|
||||
|
||||
// Owned collections — populated via .OwnsMany() in DbContext.
|
||||
public List<StoryChapterBattleSetting> BattleSettings { get; set; } = new();
|
||||
public List<StoryChapterReward> Rewards { get; set; } = new();
|
||||
public List<StorySubChapter> SubChapters { get; set; } = new();
|
||||
}
|
||||
13
SVSim.Database/Entities/Story/StoryChapterBattleSetting.cs
Normal file
13
SVSim.Database/Entities/Story/StoryChapterBattleSetting.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
[Microsoft.EntityFrameworkCore.Owned]
|
||||
public class StoryChapterBattleSetting
|
||||
{
|
||||
public int DeckClassId { get; set; }
|
||||
public int PlayerEmotionOverride { get; set; }
|
||||
public int EnemyEmotionOverride { get; set; }
|
||||
public int SkinIdOverride { get; set; }
|
||||
public int Battle3dFieldIdOverride { get; set; }
|
||||
public int BgmIdOverride { get; set; }
|
||||
public int DeckSkinIdOverride { get; set; }
|
||||
}
|
||||
9
SVSim.Database/Entities/Story/StoryChapterReward.cs
Normal file
9
SVSim.Database/Entities/Story/StoryChapterReward.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
[Microsoft.EntityFrameworkCore.Owned]
|
||||
public class StoryChapterReward
|
||||
{
|
||||
public int RewardType { get; set; }
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
}
|
||||
26
SVSim.Database/Entities/Story/StorySection.cs
Normal file
26
SVSim.Database/Entities/Story/StorySection.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
public class StorySection
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public int Id { get; set; }
|
||||
|
||||
public int? WorldId { get; set; }
|
||||
public StoryWorld? World { get; set; }
|
||||
|
||||
public StoryApiType StoryApiType { get; set; }
|
||||
public int OrderId { get; set; }
|
||||
public int AllStoryOrderId { get; set; }
|
||||
public string NameTextKey { get; set; } = string.Empty;
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public bool IsLeaderSelect { get; set; }
|
||||
public int BackGroundId { get; set; }
|
||||
public int ChapterSelectType { get; set; }
|
||||
public int StoryTypeOverwrite { get; set; }
|
||||
public bool IsUnderMaintenance { get; set; }
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
}
|
||||
9
SVSim.Database/Entities/Story/StorySubChapter.cs
Normal file
9
SVSim.Database/Entities/Story/StorySubChapter.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
[Microsoft.EntityFrameworkCore.Owned]
|
||||
public class StorySubChapter
|
||||
{
|
||||
public int SubChapterId { get; set; }
|
||||
public int SubChapterStoryId { get; set; }
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
}
|
||||
15
SVSim.Database/Entities/Story/StoryWorld.cs
Normal file
15
SVSim.Database/Entities/Story/StoryWorld.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
public class StoryWorld
|
||||
{
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public int Id { get; set; }
|
||||
|
||||
public string TitleTextKey { get; set; } = string.Empty;
|
||||
public string PanelImageName { get; set; } = string.Empty;
|
||||
public string RibbonText { get; set; } = string.Empty;
|
||||
}
|
||||
9
SVSim.Database/Entities/Story/ViewerStoryBranchUnlock.cs
Normal file
9
SVSim.Database/Entities/Story/ViewerStoryBranchUnlock.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
// Composite PK (ViewerId, StoryId) — StoryId here is the BRANCH CHILD that was unlocked.
|
||||
public class ViewerStoryBranchUnlock
|
||||
{
|
||||
public long ViewerId { get; set; }
|
||||
public int StoryId { get; set; }
|
||||
public DateTime UnlockedAt { get; set; }
|
||||
}
|
||||
13
SVSim.Database/Entities/Story/ViewerStoryProgress.cs
Normal file
13
SVSim.Database/Entities/Story/ViewerStoryProgress.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SVSim.Database.Entities.Story;
|
||||
|
||||
// Composite PK (ViewerId, StoryId) configured via fluent API in SVSimDbContext.
|
||||
public class ViewerStoryProgress
|
||||
{
|
||||
public long ViewerId { get; set; }
|
||||
public int StoryId { get; set; }
|
||||
|
||||
public bool IsFinish { get; set; }
|
||||
public bool IsSkipped { get; set; }
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
public DateTime? SkippedAt { get; set; }
|
||||
}
|
||||
2570
SVSim.Database/Migrations/20260525163848_Story.Designer.cs
generated
Normal file
2570
SVSim.Database/Migrations/20260525163848_Story.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
283
SVSim.Database/Migrations/20260525163848_Story.cs
Normal file
283
SVSim.Database/Migrations/20260525163848_Story.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Story : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SpecialBattleSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
PlayerFirstTurn = table.Column<int>(type: "integer", nullable: false),
|
||||
PlayerStartPp = table.Column<int>(type: "integer", nullable: false),
|
||||
EnemyStartPp = table.Column<int>(type: "integer", nullable: false),
|
||||
PlayerStartLife = table.Column<int>(type: "integer", nullable: false),
|
||||
EnemyStartLife = table.Column<int>(type: "integer", nullable: false),
|
||||
PlayerAttachSkill = table.Column<string>(type: "text", nullable: false),
|
||||
EnemyAttachSkill = table.Column<string>(type: "text", nullable: false),
|
||||
IdOverrideInBattleLog = table.Column<string>(type: "text", nullable: false),
|
||||
BanishEffectOverride = table.Column<string>(type: "text", nullable: false),
|
||||
TokenDrawEffectOverride = table.Column<string>(type: "text", nullable: false),
|
||||
SpecialTokenDrawEffectOverride = table.Column<string>(type: "text", nullable: false),
|
||||
ResultSkip = table.Column<int>(type: "integer", nullable: false),
|
||||
VsEffectOverride = table.Column<int>(type: "integer", nullable: false),
|
||||
ClassDestroyEffectOverride = table.Column<int>(type: "integer", nullable: false),
|
||||
Note = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SpecialBattleSettings", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoryWorlds",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
TitleTextKey = table.Column<string>(type: "text", nullable: false),
|
||||
PanelImageName = table.Column<string>(type: "text", nullable: false),
|
||||
RibbonText = table.Column<string>(type: "text", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StoryWorlds", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerStoryBranchUnlocks",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
StoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
UnlockedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerStoryBranchUnlocks", x => new { x.ViewerId, x.StoryId });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerStoryProgress",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
StoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
IsFinish = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsSkipped = table.Column<bool>(type: "boolean", nullable: false),
|
||||
FinishedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
SkippedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerStoryProgress", x => new { x.ViewerId, x.StoryId });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StorySections",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
WorldId = table.Column<int>(type: "integer", nullable: true),
|
||||
StoryApiType = table.Column<int>(type: "integer", nullable: false),
|
||||
OrderId = table.Column<int>(type: "integer", nullable: false),
|
||||
AllStoryOrderId = table.Column<int>(type: "integer", nullable: false),
|
||||
NameTextKey = table.Column<string>(type: "text", nullable: false),
|
||||
ImageName = table.Column<string>(type: "text", nullable: false),
|
||||
IsLeaderSelect = table.Column<bool>(type: "boolean", nullable: false),
|
||||
BackGroundId = table.Column<int>(type: "integer", nullable: false),
|
||||
ChapterSelectType = table.Column<int>(type: "integer", nullable: false),
|
||||
StoryTypeOverwrite = table.Column<int>(type: "integer", nullable: false),
|
||||
IsUnderMaintenance = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsPlayAnotherEndAppearanceAnimation = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StorySections", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_StorySections_StoryWorlds_WorldId",
|
||||
column: x => x.WorldId,
|
||||
principalTable: "StoryWorlds",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoryChapters",
|
||||
columns: table => new
|
||||
{
|
||||
StoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
SectionId = table.Column<int>(type: "integer", nullable: false),
|
||||
CharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
ChapterId = table.Column<string>(type: "text", nullable: false),
|
||||
NextChapterId = table.Column<string>(type: "text", nullable: false),
|
||||
RequiredChapterId = table.Column<string>(type: "text", nullable: true),
|
||||
SelectionDisplayPosition = table.Column<string>(type: "text", nullable: true),
|
||||
SelectionTextId = table.Column<string>(type: "text", nullable: true),
|
||||
XCoordinate = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
YCoordinate = table.Column<decimal>(type: "numeric", nullable: false),
|
||||
ShowCoordinate = table.Column<int>(type: "integer", nullable: false),
|
||||
IsCameraMovable = table.Column<int>(type: "integer", nullable: false),
|
||||
ShowSubtitles = table.Column<int>(type: "integer", nullable: false),
|
||||
BattleExists = table.Column<bool>(type: "boolean", nullable: false),
|
||||
EnemyCharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
EnemyClass = table.Column<int>(type: "integer", nullable: false),
|
||||
EnemyAiId = table.Column<int>(type: "integer", nullable: false),
|
||||
BgFileName = table.Column<string>(type: "text", nullable: false),
|
||||
ChapterEffectPath = table.Column<string>(type: "text", nullable: true),
|
||||
ChapterClearTextId = table.Column<string>(type: "text", nullable: true),
|
||||
Battle3dFieldId = table.Column<int>(type: "integer", nullable: false),
|
||||
BgmId = table.Column<string>(type: "text", nullable: false),
|
||||
SpecialBattleSettingId = table.Column<int>(type: "integer", nullable: true),
|
||||
ReleasePoint = table.Column<int>(type: "integer", nullable: false),
|
||||
IsMaintenanceChapter = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsPlayAnotherEndAppearanceAnimation = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsReleasedAnotherEnd = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsSkipEnabled = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StoryChapters", x => x.StoryId);
|
||||
table.ForeignKey(
|
||||
name: "FK_StoryChapters_SpecialBattleSettings_SpecialBattleSettingId",
|
||||
column: x => x.SpecialBattleSettingId,
|
||||
principalTable: "SpecialBattleSettings",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_StoryChapters_StorySections_SectionId",
|
||||
column: x => x.SectionId,
|
||||
principalTable: "StorySections",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoryChapterBattleSetting",
|
||||
columns: table => new
|
||||
{
|
||||
StoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
DeckClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
PlayerEmotionOverride = table.Column<int>(type: "integer", nullable: false),
|
||||
EnemyEmotionOverride = table.Column<int>(type: "integer", nullable: false),
|
||||
SkinIdOverride = table.Column<int>(type: "integer", nullable: false),
|
||||
Battle3dFieldIdOverride = table.Column<int>(type: "integer", nullable: false),
|
||||
BgmIdOverride = table.Column<int>(type: "integer", nullable: false),
|
||||
DeckSkinIdOverride = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StoryChapterBattleSetting", x => new { x.StoryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_StoryChapterBattleSetting_StoryChapters_StoryId",
|
||||
column: x => x.StoryId,
|
||||
principalTable: "StoryChapters",
|
||||
principalColumn: "StoryId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoryChapterReward",
|
||||
columns: table => new
|
||||
{
|
||||
StoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardNumber = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StoryChapterReward", x => new { x.StoryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_StoryChapterReward_StoryChapters_StoryId",
|
||||
column: x => x.StoryId,
|
||||
principalTable: "StoryChapters",
|
||||
principalColumn: "StoryId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StorySubChapter",
|
||||
columns: table => new
|
||||
{
|
||||
StoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
SubChapterId = table.Column<int>(type: "integer", nullable: false),
|
||||
SubChapterStoryId = table.Column<int>(type: "integer", nullable: false),
|
||||
IsMaintenanceChapter = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StorySubChapter", x => new { x.StoryId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_StorySubChapter_StoryChapters_StoryId",
|
||||
column: x => x.StoryId,
|
||||
principalTable: "StoryChapters",
|
||||
principalColumn: "StoryId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoryChapters_NextChapterId",
|
||||
table: "StoryChapters",
|
||||
column: "NextChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoryChapters_SectionId_CharaId_ChapterId",
|
||||
table: "StoryChapters",
|
||||
columns: new[] { "SectionId", "CharaId", "ChapterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoryChapters_SpecialBattleSettingId",
|
||||
table: "StoryChapters",
|
||||
column: "SpecialBattleSettingId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StorySections_WorldId",
|
||||
table: "StorySections",
|
||||
column: "WorldId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoryChapterBattleSetting");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoryChapterReward");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StorySubChapter");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerStoryBranchUnlocks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerStoryProgress");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoryChapters");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SpecialBattleSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StorySections");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoryWorlds");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,281 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("MyPageBackgroundEntryViewer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.SpecialBattleSetting", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BanishEffectOverride")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ClassDestroyEffectOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("EnemyAttachSkill")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("EnemyStartLife")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("EnemyStartPp")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("IdOverrideInBattleLog")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PlayerAttachSkill")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("PlayerFirstTurn")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("PlayerStartLife")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("PlayerStartPp")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResultSkip")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SpecialTokenDrawEffectOverride")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TokenDrawEffectOverride")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("VsEffectOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SpecialBattleSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.StoryChapter", b =>
|
||||
{
|
||||
b.Property<int>("StoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Battle3dFieldId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("BattleExists")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BgFileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("BgmId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChapterClearTextId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChapterEffectPath")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ChapterId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("CharaId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("EnemyAiId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("EnemyCharaId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("EnemyClass")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("IsCameraMovable")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsMaintenanceChapter")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsPlayAnotherEndAppearanceAnimation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsReleasedAnotherEnd")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSkipEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("NextChapterId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ReleasePoint")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("RequiredChapterId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SectionId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SelectionDisplayPosition")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SelectionTextId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ShowCoordinate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ShowSubtitles")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SpecialBattleSettingId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("XCoordinate")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<decimal>("YCoordinate")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.HasKey("StoryId");
|
||||
|
||||
b.HasIndex("NextChapterId");
|
||||
|
||||
b.HasIndex("SpecialBattleSettingId");
|
||||
|
||||
b.HasIndex("SectionId", "CharaId", "ChapterId");
|
||||
|
||||
b.ToTable("StoryChapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.StorySection", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AllStoryOrderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BackGroundId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ChapterSelectType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ImageName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsLeaderSelect")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsPlayAnotherEndAppearanceAnimation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsUnderMaintenance")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("NameTextKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("OrderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("StoryApiType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("StoryTypeOverwrite")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("WorldId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorldId");
|
||||
|
||||
b.ToTable("StorySections");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.StoryWorld", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PanelImageName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RibbonText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TitleTextKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("StoryWorlds");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.ViewerStoryBranchUnlock", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("StoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UnlockedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("ViewerId", "StoryId");
|
||||
|
||||
b.ToTable("ViewerStoryBranchUnlocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.ViewerStoryProgress", b =>
|
||||
{
|
||||
b.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("StoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsFinish")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSkipped")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("SkippedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("ViewerId", "StoryId");
|
||||
|
||||
b.ToTable("ViewerStoryProgress");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ArenaSeasonConfig", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1562,6 +1837,134 @@ namespace SVSim.Database.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.StoryChapter", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Entities.Story.StorySection", "Section")
|
||||
.WithMany()
|
||||
.HasForeignKey("SectionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("SVSim.Database.Entities.Story.SpecialBattleSetting", "SpecialBattleSetting")
|
||||
.WithMany()
|
||||
.HasForeignKey("SpecialBattleSettingId");
|
||||
|
||||
b.OwnsMany("SVSim.Database.Entities.Story.StoryChapterBattleSetting", "BattleSettings", b1 =>
|
||||
{
|
||||
b1.Property<int>("StoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<int>("Battle3dFieldIdOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("BgmIdOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("DeckClassId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("DeckSkinIdOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("EnemyEmotionOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("PlayerEmotionOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("SkinIdOverride")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("StoryId", "Id");
|
||||
|
||||
b1.ToTable("StoryChapterBattleSetting");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("StoryId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Entities.Story.StoryChapterReward", "Rewards", b1 =>
|
||||
{
|
||||
b1.Property<int>("StoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<long>("RewardDetailId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("RewardNumber")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("RewardType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("StoryId", "Id");
|
||||
|
||||
b1.ToTable("StoryChapterReward");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("StoryId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Entities.Story.StorySubChapter", "SubChapters", b1 =>
|
||||
{
|
||||
b1.Property<int>("StoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<bool>("IsMaintenanceChapter")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<int>("SubChapterId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("SubChapterStoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("StoryId", "Id");
|
||||
|
||||
b1.ToTable("StorySubChapter");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("StoryId");
|
||||
});
|
||||
|
||||
b.Navigation("BattleSettings");
|
||||
|
||||
b.Navigation("Rewards");
|
||||
|
||||
b.Navigation("Section");
|
||||
|
||||
b.Navigation("SpecialBattleSetting");
|
||||
|
||||
b.Navigation("SubChapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Entities.Story.StorySection", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Entities.Story.StoryWorld", "World")
|
||||
.WithMany()
|
||||
.HasForeignKey("WorldId");
|
||||
|
||||
b.Navigation("World");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.CardCosmeticReward", b =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card")
|
||||
|
||||
9
SVSim.Database/Models/Config/StoryConfig.cs
Normal file
9
SVSim.Database/Models/Config/StoryConfig.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
[ConfigSection("Story")]
|
||||
public class StoryConfig
|
||||
{
|
||||
public int ClassXpPerClear { get; set; } = 200;
|
||||
|
||||
public static StoryConfig ShippedDefaults() => new();
|
||||
}
|
||||
19
SVSim.Database/Repositories/Story/IStoryMasterRepository.cs
Normal file
19
SVSim.Database/Repositories/Story/IStoryMasterRepository.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using SVSim.Database.Entities.Story;
|
||||
|
||||
namespace SVSim.Database.Repositories.Story;
|
||||
|
||||
public interface IStoryMasterRepository
|
||||
{
|
||||
Task<List<StorySection>> GetSectionsByFamilyAsync(StoryApiType apiType);
|
||||
Task<List<StoryWorld>> GetWorldsForSectionsAsync(IEnumerable<int> worldIds);
|
||||
Task<List<StoryChapter>> GetChaptersBySectionCharaAsync(int sectionId, int charaId);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-load chapter scalars (no owned collections) across multiple sections in one round-trip.
|
||||
/// Used by the section rollup to avoid N+1 per (section, chara) lookups.
|
||||
/// </summary>
|
||||
Task<List<StoryChapter>> GetChaptersBySectionsAsync(IEnumerable<int> sectionIds);
|
||||
|
||||
Task<StoryChapter?> GetChapterByIdAsync(int storyId);
|
||||
Task<SpecialBattleSetting?> GetSbsByIdAsync(int sbsId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using SVSim.Database.Entities.Story;
|
||||
|
||||
namespace SVSim.Database.Repositories.Story;
|
||||
|
||||
public interface IViewerStoryProgressRepository
|
||||
{
|
||||
Task<Dictionary<int, ViewerStoryProgress>> GetProgressForChaptersAsync(long viewerId, IEnumerable<int> storyIds);
|
||||
Task<HashSet<int>> GetBranchUnlockedStoryIdsAsync(long viewerId, IEnumerable<int> storyIds);
|
||||
|
||||
Task UpsertProgressAsync(long viewerId, int storyId, bool? isFinish, bool? isSkipped);
|
||||
Task UpsertBranchUnlockAsync(long viewerId, int storyId);
|
||||
}
|
||||
49
SVSim.Database/Repositories/Story/StoryMasterRepository.cs
Normal file
49
SVSim.Database/Repositories/Story/StoryMasterRepository.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Entities.Story;
|
||||
|
||||
namespace SVSim.Database.Repositories.Story;
|
||||
|
||||
public class StoryMasterRepository : IStoryMasterRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public StoryMasterRepository(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public Task<List<StorySection>> GetSectionsByFamilyAsync(StoryApiType apiType)
|
||||
{
|
||||
var families = apiType == StoryApiType.AllStory
|
||||
? new[] { StoryApiType.Main } // AllStory effectively returns Main per spec
|
||||
: new[] { apiType };
|
||||
return _db.StorySections.Where(s => families.Contains(s.StoryApiType))
|
||||
.OrderBy(s => s.AllStoryOrderId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<StoryWorld>> GetWorldsForSectionsAsync(IEnumerable<int> worldIds)
|
||||
=> _db.StoryWorlds.Where(w => worldIds.Contains(w.Id)).ToListAsync();
|
||||
|
||||
public Task<List<StoryChapter>> GetChaptersBySectionCharaAsync(int sectionId, int charaId)
|
||||
=> _db.StoryChapters
|
||||
.Include(c => c.BattleSettings).Include(c => c.Rewards).Include(c => c.SubChapters)
|
||||
.Where(c => c.SectionId == sectionId && c.CharaId == charaId)
|
||||
.ToListAsync();
|
||||
|
||||
// No Includes — the rollup only reads SectionId/CharaId/StoryId. Including the three owned
|
||||
// collections here would cartesian-explode across ~677 chapters and turn a single query into
|
||||
// a multi-MB result set.
|
||||
public Task<List<StoryChapter>> GetChaptersBySectionsAsync(IEnumerable<int> sectionIds)
|
||||
{
|
||||
var ids = sectionIds.ToList();
|
||||
return _db.StoryChapters
|
||||
.AsNoTracking()
|
||||
.Where(c => ids.Contains(c.SectionId))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<StoryChapter?> GetChapterByIdAsync(int storyId)
|
||||
=> _db.StoryChapters
|
||||
.Include(c => c.BattleSettings).Include(c => c.Rewards).Include(c => c.SubChapters)
|
||||
.FirstOrDefaultAsync(c => c.StoryId == storyId);
|
||||
|
||||
public Task<SpecialBattleSetting?> GetSbsByIdAsync(int sbsId)
|
||||
=> _db.SpecialBattleSettings.FirstOrDefaultAsync(s => s.Id == sbsId);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Entities.Story;
|
||||
|
||||
namespace SVSim.Database.Repositories.Story;
|
||||
|
||||
public class ViewerStoryProgressRepository : IViewerStoryProgressRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public ViewerStoryProgressRepository(SVSimDbContext db) { _db = db; }
|
||||
|
||||
public async Task<Dictionary<int, ViewerStoryProgress>> GetProgressForChaptersAsync(
|
||||
long viewerId, IEnumerable<int> storyIds)
|
||||
{
|
||||
var ids = storyIds.ToList();
|
||||
var rows = await _db.ViewerStoryProgress
|
||||
.Where(p => p.ViewerId == viewerId && ids.Contains(p.StoryId))
|
||||
.ToListAsync();
|
||||
return rows.ToDictionary(r => r.StoryId);
|
||||
}
|
||||
|
||||
public async Task<HashSet<int>> GetBranchUnlockedStoryIdsAsync(long viewerId, IEnumerable<int> storyIds)
|
||||
{
|
||||
var ids = storyIds.ToList();
|
||||
var rows = await _db.ViewerStoryBranchUnlocks
|
||||
.Where(u => u.ViewerId == viewerId && ids.Contains(u.StoryId))
|
||||
.Select(u => u.StoryId)
|
||||
.ToListAsync();
|
||||
return new HashSet<int>(rows);
|
||||
}
|
||||
|
||||
public async Task UpsertProgressAsync(long viewerId, int storyId, bool? isFinish, bool? isSkipped)
|
||||
{
|
||||
var row = await _db.ViewerStoryProgress.FirstOrDefaultAsync(
|
||||
p => p.ViewerId == viewerId && p.StoryId == storyId);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ViewerStoryProgress { ViewerId = viewerId, StoryId = storyId };
|
||||
_db.ViewerStoryProgress.Add(row);
|
||||
}
|
||||
if (isFinish.HasValue) { row.IsFinish = isFinish.Value; if (isFinish.Value) row.FinishedAt = DateTime.UtcNow; }
|
||||
if (isSkipped.HasValue) { row.IsSkipped = isSkipped.Value; if (isSkipped.Value) row.SkippedAt = DateTime.UtcNow; }
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task UpsertBranchUnlockAsync(long viewerId, int storyId)
|
||||
{
|
||||
bool exists = await _db.ViewerStoryBranchUnlocks
|
||||
.AnyAsync(u => u.ViewerId == viewerId && u.StoryId == storyId);
|
||||
if (!exists)
|
||||
{
|
||||
_db.ViewerStoryBranchUnlocks.Add(new ViewerStoryBranchUnlock
|
||||
{ ViewerId = viewerId, StoryId = storyId, UnlockedAt = DateTime.UtcNow });
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Common;
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
|
||||
@@ -67,6 +68,14 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<PuzzleMissionEntry> PuzzleMissions => Set<PuzzleMissionEntry>();
|
||||
public DbSet<ViewerPuzzleClear> ViewerPuzzleClears => Set<ViewerPuzzleClear>();
|
||||
|
||||
// Story reference data + viewer progress
|
||||
public DbSet<StoryWorld> StoryWorlds => Set<StoryWorld>();
|
||||
public DbSet<StorySection> StorySections => Set<StorySection>();
|
||||
public DbSet<StoryChapter> StoryChapters => Set<StoryChapter>();
|
||||
public DbSet<SpecialBattleSetting> SpecialBattleSettings => Set<SpecialBattleSetting>();
|
||||
public DbSet<ViewerStoryProgress> ViewerStoryProgress => Set<ViewerStoryProgress>();
|
||||
public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>();
|
||||
|
||||
#endregion
|
||||
|
||||
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
@@ -142,6 +151,29 @@ public class SVSimDbContext : DbContext
|
||||
.HasColumnType("jsonb");
|
||||
}
|
||||
|
||||
// --- Story entities ---
|
||||
|
||||
// Composite PKs for viewer-state tables
|
||||
modelBuilder.Entity<ViewerStoryProgress>().HasKey(x => new { x.ViewerId, x.StoryId });
|
||||
modelBuilder.Entity<ViewerStoryBranchUnlock>().HasKey(x => new { x.ViewerId, x.StoryId });
|
||||
|
||||
// StoryChapter owned collections (shadow-PK per row)
|
||||
modelBuilder.Entity<StoryChapter>(c =>
|
||||
{
|
||||
c.OwnsMany(x => x.BattleSettings, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property<int>("Id"); b.HasKey("StoryId", "Id"); });
|
||||
c.OwnsMany(x => x.Rewards, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property<int>("Id"); b.HasKey("StoryId", "Id"); });
|
||||
c.OwnsMany(x => x.SubChapters, b => { b.WithOwner().HasForeignKey("StoryId"); b.Property<int>("Id"); b.HasKey("StoryId", "Id"); });
|
||||
});
|
||||
|
||||
// FK relationships
|
||||
modelBuilder.Entity<StorySection>().HasOne(s => s.World).WithMany().HasForeignKey(s => s.WorldId);
|
||||
modelBuilder.Entity<StoryChapter>().HasOne(c => c.Section).WithMany().HasForeignKey(c => c.SectionId);
|
||||
modelBuilder.Entity<StoryChapter>().HasOne(c => c.SpecialBattleSetting).WithMany().HasForeignKey(c => c.SpecialBattleSettingId);
|
||||
|
||||
// Indexes
|
||||
modelBuilder.Entity<StoryChapter>().HasIndex(c => new { c.SectionId, c.CharaId, c.ChapterId });
|
||||
modelBuilder.Entity<StoryChapter>().HasIndex(c => c.NextChapterId);
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ public class MyPageController : SVSimController
|
||||
{
|
||||
UserMyPageSetting = new MyPageBgSetting(),
|
||||
},
|
||||
BasicPuzzle = new BasicPuzzleBadge { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
BasicPuzzle = new Models.Dtos.Common.BadgeFlag { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||
|
||||
88
SVSim.EmulatedEntrypoint/Controllers/StoryController.cs
Normal file
88
SVSim.EmulatedEntrypoint/Controllers/StoryController.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class StoryController : SVSimController
|
||||
{
|
||||
private readonly IStoryService _service;
|
||||
public StoryController(IStoryService service) { _service = service; }
|
||||
|
||||
[HttpPost("/story/section")]
|
||||
[HttpPost("/main_story/section")]
|
||||
[HttpPost("/limited_story/section")]
|
||||
[HttpPost("/event_story/section")]
|
||||
public async Task<ActionResult<SectionResponse>> Section(SectionRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.GetSectionsAsync(ResolveApiType(), vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/leader_select")]
|
||||
[HttpPost("/limited_story/leader_select")]
|
||||
[HttpPost("/event_story/leader_select")]
|
||||
public async Task<ActionResult<LeaderSelectResponse>> LeaderSelect(LeaderSelectRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.GetLeaderSelectAsync(ResolveApiType(), req.SectionId, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/info")]
|
||||
[HttpPost("/limited_story/info")]
|
||||
[HttpPost("/event_story/info")]
|
||||
public async Task<ActionResult<InfoResponse>> Info(InfoRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
int? chara = req.CharaId == 0 ? null : req.CharaId;
|
||||
return await _service.GetInfoAsync(ResolveApiType(), req.SectionId, chara, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/get_deck_list")]
|
||||
[HttpPost("/event_story/get_deck_list")]
|
||||
public async Task<ActionResult<GetDeckListResponse>> GetDeckList(GetDeckListRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.GetDeckListAsync(ResolveApiType(), req.StoryId, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/start")]
|
||||
[HttpPost("/limited_story/start")]
|
||||
[HttpPost("/event_story/start")]
|
||||
public async Task<ActionResult<StartResponse>> Start(StartRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.StartAsync(ResolveApiType(), req.StoryIds, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/finish")]
|
||||
[HttpPost("/limited_story/finish")]
|
||||
[HttpPost("/event_story/finish")]
|
||||
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.FinishAsync(ResolveApiType(), req, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/all_finish")]
|
||||
[HttpPost("/limited_story/all_finish")]
|
||||
[HttpPost("/event_story/all_finish")]
|
||||
public async Task<ActionResult<FinishResponse>> AllFinish(AllFinishRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.AllFinishAsync(ResolveApiType(), req.StoryIds, req.IsFinish == 1, vid);
|
||||
}
|
||||
|
||||
private StoryApiType ResolveApiType()
|
||||
{
|
||||
var path = HttpContext.Request.Path.Value ?? "";
|
||||
if (path.StartsWith("/main_story")) return StoryApiType.Main;
|
||||
if (path.StartsWith("/limited_story")) return StoryApiType.Limited;
|
||||
if (path.StartsWith("/event_story")) return StoryApiType.Event;
|
||||
return StoryApiType.AllStory; // /story/section
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// basic_puzzle.is_display_badge — drives the "practice puzzle" badge on the
|
||||
/// footer. Read by MyPageTask.cs:177.
|
||||
///
|
||||
/// Named with the "Badge" suffix to avoid colliding with the
|
||||
/// <c>Models.Dtos.{Common,Requests,Responses}.BasicPuzzle</c> sub-namespaces
|
||||
/// that hold the /basic_puzzle/* endpoint DTOs.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BasicPuzzleBadge
|
||||
{
|
||||
[JsonPropertyName("is_display_badge")]
|
||||
[Key("is_display_badge")]
|
||||
public bool IsDisplayBadge { get; set; }
|
||||
}
|
||||
24
SVSim.EmulatedEntrypoint/Models/Dtos/Common/BadgeFlag.cs
Normal file
24
SVSim.EmulatedEntrypoint/Models/Dtos/Common/BadgeFlag.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Single-field `{ "is_display_badge": bool }` wrapper. The badge-poll context
|
||||
/// of <c>MyPageNotifications.ParseBadgeInfos</c> (called from StoryFinishTask,
|
||||
/// QuestFinishTask, RecoveryTask, OpenRoomBattleGetRecoveryParamTask) reads
|
||||
/// only this one field from each of <c>quest</c>, <c>story_notification</c>,
|
||||
/// and <c>basic_puzzle</c>, so all three positions share this shape.
|
||||
///
|
||||
/// The mypage-index versions of <c>quest</c> and <c>story_notification</c> have
|
||||
/// richer shapes (<see cref="SVSim.EmulatedEntrypoint.Models.Dtos.Quest"/>,
|
||||
/// <see cref="SVSim.EmulatedEntrypoint.Models.Dtos.StoryNotification"/>) since
|
||||
/// the home-screen UI reads additional fields off them.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BadgeFlag
|
||||
{
|
||||
[JsonPropertyName("is_display_badge")]
|
||||
[Key("is_display_badge")]
|
||||
public bool IsDisplayBadge { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Flat 4-bool form of <c>shop_notification</c> returned by the badge-poll
|
||||
/// endpoints (StoryFinish, QuestFinish, Recovery, OpenRoomBattleGetRecoveryParam).
|
||||
/// Each bool drives the corresponding shop tab's footer badge via
|
||||
/// <c>ShopNotification.SetShopBadgeEnable</c> (Wizard/ShopNotification.cs:63),
|
||||
/// which calls <c>.ToBoolean()</c> on each directly.
|
||||
///
|
||||
/// Distinct from <see cref="SVSim.EmulatedEntrypoint.Models.Dtos.ShopNotification"/>,
|
||||
/// which is the richer mypage-index shape (each sub-key holds a detail object
|
||||
/// instead of a bool, for the home-screen's animated shop appeals).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ShopNotificationBadges
|
||||
{
|
||||
[JsonPropertyName("card_pack")]
|
||||
[Key("card_pack")]
|
||||
public bool CardPack { get; set; }
|
||||
|
||||
[JsonPropertyName("build_deck")]
|
||||
[Key("build_deck")]
|
||||
public bool BuildDeck { get; set; }
|
||||
|
||||
[JsonPropertyName("sleeve")]
|
||||
[Key("sleeve")]
|
||||
public bool Sleeve { get; set; }
|
||||
|
||||
[JsonPropertyName("leader_skin")]
|
||||
[Key("leader_skin")]
|
||||
public bool LeaderSkin { get; set; }
|
||||
}
|
||||
@@ -222,7 +222,7 @@ public class MyPageIndexResponse
|
||||
|
||||
[JsonPropertyName("basic_puzzle")]
|
||||
[Key("basic_puzzle")]
|
||||
public BasicPuzzleBadge BasicPuzzle { get; set; } = new();
|
||||
public Common.BadgeFlag BasicPuzzle { get; set; } = new();
|
||||
|
||||
// ── Battle Pass period flag ────────────────────────────────────────────
|
||||
|
||||
|
||||
17
SVSim.EmulatedEntrypoint/Models/Dtos/Story/AllFinishDtos.cs
Normal file
17
SVSim.EmulatedEntrypoint/Models/Dtos/Story/AllFinishDtos.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class AllFinishRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_ids")]
|
||||
[Key("story_ids")]
|
||||
public int[] StoryIds { get; set; } = Array.Empty<int>();
|
||||
|
||||
[JsonPropertyName("is_finish")]
|
||||
[Key("is_finish")]
|
||||
public int IsFinish { get; set; }
|
||||
}
|
||||
158
SVSim.EmulatedEntrypoint/Models/Dtos/Story/FinishDtos.cs
Normal file
158
SVSim.EmulatedEntrypoint/Models/Dtos/Story/FinishDtos.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
// GatheringInfo and CompetitionInfo resolve via the parent Models.Dtos namespace (C# walks outward).
|
||||
|
||||
[MessagePackObject]
|
||||
public class FinishRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_id")]
|
||||
[Key("story_id")]
|
||||
public int StoryId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finish")]
|
||||
[Key("is_finish")]
|
||||
public int IsFinish { get; set; }
|
||||
|
||||
// Battle-shape fields (present only on play-shape)
|
||||
[JsonPropertyName("evolve_count")]
|
||||
[Key("evolve_count")]
|
||||
public int? EvolveCount { get; set; }
|
||||
|
||||
[JsonPropertyName("total_turn")]
|
||||
[Key("total_turn")]
|
||||
public int? TotalTurn { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_no")]
|
||||
[Key("deck_no")]
|
||||
public int? DeckNo { get; set; }
|
||||
|
||||
[JsonPropertyName("use_build_deck")]
|
||||
[Key("use_build_deck")]
|
||||
public int? UseBuildDeck { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_format")]
|
||||
[Key("deck_format")]
|
||||
public int? DeckFormat { get; set; }
|
||||
|
||||
[JsonPropertyName("class_id")]
|
||||
[Key("class_id")]
|
||||
public int? ClassId { get; set; }
|
||||
|
||||
[JsonPropertyName("mission")]
|
||||
[Key("mission")]
|
||||
public Dictionary<string, int>? Mission { get; set; }
|
||||
|
||||
[JsonPropertyName("recovery_data")]
|
||||
[Key("recovery_data")]
|
||||
public string? RecoveryData { get; set; }
|
||||
|
||||
// Misspelled the same way in every solo finish endpoint — preserved on the wire.
|
||||
[JsonPropertyName("prosessing_time_data")]
|
||||
[Key("prosessing_time_data")]
|
||||
public string[]? ProsessingTimeData { get; set; }
|
||||
|
||||
// No-battle-shape fields
|
||||
[JsonPropertyName("selection_chapter_id")]
|
||||
[Key("selection_chapter_id")]
|
||||
public string? SelectionChapterId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_select_another_end")]
|
||||
[Key("is_select_another_end")]
|
||||
public bool? IsSelectAnotherEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Derived: true when the request carries battle-shape fields (ClassId present = play-shape).
|
||||
/// Kept off both serializations.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
public bool IsPlayShape => ClassId.HasValue;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class FinishResponse
|
||||
{
|
||||
[JsonPropertyName("get_class_experience")]
|
||||
[Key("get_class_experience")]
|
||||
public string GetClassExperience { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("class_experience")]
|
||||
[Key("class_experience")]
|
||||
public int ClassExperience { get; set; }
|
||||
|
||||
[JsonPropertyName("class_level")]
|
||||
[Key("class_level")]
|
||||
public string ClassLevel { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("achieved_info")]
|
||||
[Key("achieved_info")]
|
||||
public Dictionary<string, object> AchievedInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
public List<RewardGrant> RewardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("story_reward_list")]
|
||||
[Key("story_reward_list")]
|
||||
public List<RewardGrant> StoryRewardList { get; set; } = new();
|
||||
|
||||
// ─── Post-action mypage badge cluster ───
|
||||
//
|
||||
// MyPageNotifications.ParseBadgeInfos (Wizard/MyPageNotifications.cs:9) reads every key below
|
||||
// unguardedly; omitting any one throws KeyNotFoundException in Cute.NetworkManager.Connect and
|
||||
// aborts the response. The same cluster ships from every endpoint that calls ParseBadgeInfos
|
||||
// (StoryFinishTask, QuestFinishTask, RecoveryTask, OpenRoomBattleGetRecoveryParamTask).
|
||||
|
||||
[JsonPropertyName("quest")]
|
||||
[Key("quest")]
|
||||
public BadgeFlag Quest { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("story_notification")]
|
||||
[Key("story_notification")]
|
||||
public BadgeFlag StoryNotification { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("basic_puzzle")]
|
||||
[Key("basic_puzzle")]
|
||||
public BadgeFlag BasicPuzzle { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("shop_notification")]
|
||||
[Key("shop_notification")]
|
||||
public ShopNotificationBadges ShopNotification { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("receive_friend_apply_count")]
|
||||
[Key("receive_friend_apply_count")]
|
||||
public int ReceiveFriendApplyCount { get; set; }
|
||||
|
||||
[JsonPropertyName("gathering_info")]
|
||||
[Key("gathering_info")]
|
||||
public GatheringInfo GatheringInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("competition_info")]
|
||||
[Key("competition_info")]
|
||||
public CompetitionInfo CompetitionInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("is_available_colosseum_free_entry")]
|
||||
[Key("is_available_colosseum_free_entry")]
|
||||
public bool IsAvailableColosseumFreeEntry { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class RewardGrant
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public string RewardType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_id")]
|
||||
[Key("reward_id")]
|
||||
public string RewardId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_num")]
|
||||
[Key("reward_num")]
|
||||
public string RewardNum { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class GetDeckListRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_id")]
|
||||
[Key("story_id")]
|
||||
public int StoryId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class GetDeckListResponse
|
||||
{
|
||||
[JsonPropertyName("user_deck_rotation")]
|
||||
[Key("user_deck_rotation")]
|
||||
public List<UserDeck> UserDeckRotation { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("user_deck_unlimited")]
|
||||
[Key("user_deck_unlimited")]
|
||||
public List<UserDeck> UserDeckUnlimited { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("maintenance_card_list")]
|
||||
[Key("maintenance_card_list")]
|
||||
public List<long> MaintenanceCardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("build_deck_list")]
|
||||
[Key("build_deck_list")]
|
||||
public List<BuildDeck> BuildDeckList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeck
|
||||
{
|
||||
// Placeholder — build decks return [] for v1 per spec.
|
||||
}
|
||||
222
SVSim.EmulatedEntrypoint/Models/Dtos/Story/InfoDtos.cs
Normal file
222
SVSim.EmulatedEntrypoint/Models/Dtos/Story/InfoDtos.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class InfoRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public int SectionId { get; set; }
|
||||
|
||||
[JsonPropertyName("chara_id")]
|
||||
[Key("chara_id")]
|
||||
public int CharaId { get; set; } // 0 for non-leader-select
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class InfoResponse
|
||||
{
|
||||
[JsonPropertyName("story_master_list")]
|
||||
[Key("story_master_list")]
|
||||
public List<StoryMasterEntry> StoryMasterList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("maintenance_card_list")]
|
||||
[Key("maintenance_card_list")]
|
||||
public List<long> MaintenanceCardList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class StoryMasterEntry
|
||||
{
|
||||
[JsonPropertyName("story_id")]
|
||||
[Key("story_id")]
|
||||
public string StoryId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public string SectionId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chara_id")]
|
||||
[Key("chara_id")]
|
||||
public string CharaId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chapter_id")]
|
||||
[Key("chapter_id")]
|
||||
public string ChapterId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_lock")]
|
||||
[Key("is_lock")]
|
||||
public bool IsLock { get; set; }
|
||||
|
||||
[JsonPropertyName("next_chapter_id")]
|
||||
[Key("next_chapter_id")]
|
||||
public string NextChapterId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("required_chapter_id")]
|
||||
[Key("required_chapter_id")]
|
||||
public string RequiredChapterId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("selection_display_position")]
|
||||
[Key("selection_display_position")]
|
||||
public string SelectionDisplayPosition { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("selection_text_id")]
|
||||
[Key("selection_text_id")]
|
||||
public string SelectionTextId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("show_coordinate")]
|
||||
[Key("show_coordinate")]
|
||||
public string ShowCoordinate { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("x_coordinate")]
|
||||
[Key("x_coordinate")]
|
||||
public string XCoordinate { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("y_coordinate")]
|
||||
[Key("y_coordinate")]
|
||||
public string YCoordinate { get; set; } = "";
|
||||
|
||||
// Wire typo preserved: note the space in "is_camera_ movable"
|
||||
[JsonPropertyName("is_camera_ movable")]
|
||||
[Key("is_camera_ movable")]
|
||||
public string IsCameraMovable { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("show_subtitles")]
|
||||
[Key("show_subtitles")]
|
||||
public string ShowSubtitles { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("battle_exists")]
|
||||
[Key("battle_exists")]
|
||||
public bool BattleExists { get; set; }
|
||||
|
||||
[JsonPropertyName("enemy_chara_id")]
|
||||
[Key("enemy_chara_id")]
|
||||
public string EnemyCharaId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_class")]
|
||||
[Key("enemy_class")]
|
||||
public string EnemyClass { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_ai_id")]
|
||||
[Key("enemy_ai_id")]
|
||||
public string EnemyAiId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("bg_file_name")]
|
||||
[Key("bg_file_name")]
|
||||
public string BgFileName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chapter_effect_path")]
|
||||
[Key("chapter_effect_path")]
|
||||
public string ChapterEffectPath { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chapter_clear_text_id")]
|
||||
[Key("chapter_clear_text_id")]
|
||||
public string ChapterClearTextId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("battle3dfield_id")]
|
||||
[Key("battle3dfield_id")]
|
||||
public string Battle3dFieldId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("bgm_id")]
|
||||
[Key("bgm_id")]
|
||||
public string BgmId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("special_battle_setting_id")]
|
||||
[Key("special_battle_setting_id")]
|
||||
public string SpecialBattleSettingId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("release_point")]
|
||||
[Key("release_point")]
|
||||
public string ReleasePoint { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("battle_settings")]
|
||||
[Key("battle_settings")]
|
||||
public List<BattleSettingDto> BattleSettings { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("story_reward")]
|
||||
[Key("story_reward")]
|
||||
public List<RewardDto> StoryReward { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("is_maintenance_chapter")]
|
||||
[Key("is_maintenance_chapter")]
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
|
||||
[JsonPropertyName("is_released")]
|
||||
[Key("is_released")]
|
||||
public bool IsReleased { get; set; }
|
||||
|
||||
[JsonPropertyName("is_skipped")]
|
||||
[Key("is_skipped")]
|
||||
public bool IsSkipped { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finish")]
|
||||
[Key("is_finish")]
|
||||
public bool IsFinish { get; set; }
|
||||
|
||||
[JsonPropertyName("unlock_text")]
|
||||
[Key("unlock_text")]
|
||||
public string UnlockText { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_play_another_end_appearance_animation")]
|
||||
[Key("is_play_another_end_appearance_animation")]
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
|
||||
[JsonPropertyName("is_released_another_end")]
|
||||
[Key("is_released_another_end")]
|
||||
public bool IsReleasedAnotherEnd { get; set; }
|
||||
|
||||
[JsonPropertyName("is_skip_enabled")]
|
||||
[Key("is_skip_enabled")]
|
||||
public bool IsSkipEnabled { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class BattleSettingDto
|
||||
{
|
||||
[JsonPropertyName("deck_class_id")]
|
||||
[Key("deck_class_id")]
|
||||
public int DeckClassId { get; set; }
|
||||
|
||||
[JsonPropertyName("player_emotion_override")]
|
||||
[Key("player_emotion_override")]
|
||||
public int PlayerEmotionOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("enemy_emotion_override")]
|
||||
[Key("enemy_emotion_override")]
|
||||
public int EnemyEmotionOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("skin_id_override")]
|
||||
[Key("skin_id_override")]
|
||||
public int SkinIdOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("battle3dfield_id_override")]
|
||||
[Key("battle3dfield_id_override")]
|
||||
public int Battle3dFieldIdOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("bgm_id_override")]
|
||||
[Key("bgm_id_override")]
|
||||
public int BgmIdOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_skin_id_override")]
|
||||
[Key("deck_skin_id_override")]
|
||||
public int DeckSkinIdOverride { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class RewardDto
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public string RewardType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_detail_id")]
|
||||
[Key("reward_detail_id")]
|
||||
public string RewardDetailId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_number")]
|
||||
[Key("reward_number")]
|
||||
public string RewardNumber { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class LeaderSelectRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public int SectionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class LeaderSelectResponse
|
||||
{
|
||||
[JsonPropertyName("leader_list")]
|
||||
[Key("leader_list")]
|
||||
public List<LeaderEntry> LeaderList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("leader_count")]
|
||||
[Key("leader_count")]
|
||||
public int LeaderCount { get; set; } = 8;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class LeaderEntry
|
||||
{
|
||||
[JsonPropertyName("chara_id")]
|
||||
[Key("chara_id")]
|
||||
public int CharaId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_skipped")]
|
||||
[Key("is_skipped")]
|
||||
public bool IsSkipped { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finished")]
|
||||
[Key("is_finished")]
|
||||
public bool IsFinished { get; set; }
|
||||
|
||||
[JsonPropertyName("current_chapter")]
|
||||
[Key("current_chapter")]
|
||||
public int CurrentChapter { get; set; }
|
||||
}
|
||||
109
SVSim.EmulatedEntrypoint/Models/Dtos/Story/SectionDtos.cs
Normal file
109
SVSim.EmulatedEntrypoint/Models/Dtos/Story/SectionDtos.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("is_disp_first_tips")]
|
||||
[Key("is_disp_first_tips")]
|
||||
public bool IsDispFirstTips { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionResponse
|
||||
{
|
||||
[JsonPropertyName("world_list")]
|
||||
[Key("world_list")]
|
||||
public Dictionary<string, SectionWorld> WorldList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionWorld
|
||||
{
|
||||
[JsonPropertyName("title_text_id")]
|
||||
[Key("title_text_id")]
|
||||
public string TitleTextId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("panel_image_name")]
|
||||
[Key("panel_image_name")]
|
||||
public string PanelImageName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ribbon_text")]
|
||||
[Key("ribbon_text")]
|
||||
public string RibbonText { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_complete")]
|
||||
[Key("is_complete")]
|
||||
public bool IsComplete { get; set; }
|
||||
|
||||
[JsonPropertyName("section_list")]
|
||||
[Key("section_list")]
|
||||
public List<SectionEntry> SectionList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionEntry
|
||||
{
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public string SectionId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("order_id")]
|
||||
[Key("order_id")]
|
||||
public int OrderId { get; set; }
|
||||
|
||||
[JsonPropertyName("all_story_order_id")]
|
||||
[Key("all_story_order_id")]
|
||||
public string AllStoryOrderId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
[Key("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("image_name")]
|
||||
[Key("image_name")]
|
||||
public string ImageName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_leader_select")]
|
||||
[Key("is_leader_select")]
|
||||
public bool IsLeaderSelect { get; set; }
|
||||
|
||||
[JsonPropertyName("back_ground_id")]
|
||||
[Key("back_ground_id")]
|
||||
public int BackGroundId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finished")]
|
||||
[Key("is_finished")]
|
||||
public bool IsFinished { get; set; }
|
||||
|
||||
[JsonPropertyName("released_chara_count")]
|
||||
[Key("released_chara_count")]
|
||||
public int ReleasedCharaCount { get; set; }
|
||||
|
||||
[JsonPropertyName("finished_chara_count")]
|
||||
[Key("finished_chara_count")]
|
||||
public int FinishedCharaCount { get; set; }
|
||||
|
||||
[JsonPropertyName("is_under_maintenance")]
|
||||
[Key("is_under_maintenance")]
|
||||
public bool IsUnderMaintenance { get; set; }
|
||||
|
||||
[JsonPropertyName("chapter_select_type")]
|
||||
[Key("chapter_select_type")]
|
||||
public string ChapterSelectType { get; set; } = "1";
|
||||
|
||||
[JsonPropertyName("story_type_overwrite")]
|
||||
[Key("story_type_overwrite")]
|
||||
public string StoryTypeOverwrite { get; set; } = "1";
|
||||
|
||||
[JsonPropertyName("is_new")]
|
||||
[Key("is_new")]
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
[JsonPropertyName("is_play_another_end_appearance_animation")]
|
||||
[Key("is_play_another_end_appearance_animation")]
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
}
|
||||
97
SVSim.EmulatedEntrypoint/Models/Dtos/Story/StartDtos.cs
Normal file
97
SVSim.EmulatedEntrypoint/Models/Dtos/Story/StartDtos.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class StartRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_ids")]
|
||||
[Key("story_ids")]
|
||||
public int[] StoryIds { get; set; } = Array.Empty<int>();
|
||||
}
|
||||
|
||||
// The `start` response is dynamic — each numeric key corresponds to a request story_ids index.
|
||||
// We use a Dictionary<string, object> to support both the populated and empty slot shapes.
|
||||
// MessagePack handles Dictionary natively; no [MessagePackObject] needed here.
|
||||
public class StartResponse : Dictionary<string, object>
|
||||
{
|
||||
public void AddSlot(int index, object slotPayload) => this[index.ToString()] = slotPayload;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class StartSlotWithSbs
|
||||
{
|
||||
[JsonPropertyName("special_battle_setting")]
|
||||
[Key("special_battle_setting")]
|
||||
public SpecialBattleSettingDto SpecialBattleSetting { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SpecialBattleSettingDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
[Key("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_first_turn")]
|
||||
[Key("player_first_turn")]
|
||||
public string PlayerFirstTurn { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_start_pp")]
|
||||
[Key("player_start_pp")]
|
||||
public string PlayerStartPp { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_start_pp")]
|
||||
[Key("enemy_start_pp")]
|
||||
public string EnemyStartPp { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_start_life")]
|
||||
[Key("player_start_life")]
|
||||
public string PlayerStartLife { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_start_life")]
|
||||
[Key("enemy_start_life")]
|
||||
public string EnemyStartLife { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_attach_skill")]
|
||||
[Key("player_attach_skill")]
|
||||
public string PlayerAttachSkill { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_attach_skill")]
|
||||
[Key("enemy_attach_skill")]
|
||||
public string EnemyAttachSkill { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("id_override_in_battle_log")]
|
||||
[Key("id_override_in_battle_log")]
|
||||
public string IdOverrideInBattleLog { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("banish_effect_override")]
|
||||
[Key("banish_effect_override")]
|
||||
public string BanishEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("token_draw_effect_override")]
|
||||
[Key("token_draw_effect_override")]
|
||||
public string TokenDrawEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("special_token_draw_effect_override")]
|
||||
[Key("special_token_draw_effect_override")]
|
||||
public string SpecialTokenDrawEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("result_skip")]
|
||||
[Key("result_skip")]
|
||||
public string ResultSkip { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("vs_effect_override")]
|
||||
[Key("vs_effect_override")]
|
||||
public string VsEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("class_destroy_effect_override")]
|
||||
[Key("class_destroy_effect_override")]
|
||||
public string ClassDestroyEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
[Key("note")]
|
||||
public string Note { get; set; } = "";
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Pack;
|
||||
using SVSim.Database.Repositories.Story;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
@@ -44,7 +45,12 @@ public class Program
|
||||
});
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
// Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs
|
||||
// BasicPuzzle.StartRequest) by qualifying schema ids with the full type name.
|
||||
c.CustomSchemaIds(t => t.FullName?.Replace("+", "."));
|
||||
});
|
||||
builder.Services.AddHttpLogging(opt =>
|
||||
{
|
||||
|
||||
@@ -75,6 +81,9 @@ public class Program
|
||||
builder.Services.AddScoped<PackOpenService>();
|
||||
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
||||
builder.Services.AddScoped<RewardGrantService>();
|
||||
builder.Services.AddScoped<IStoryMasterRepository, StoryMasterRepository>();
|
||||
builder.Services.AddScoped<IViewerStoryProgressRepository, ViewerStoryProgressRepository>();
|
||||
builder.Services.AddScoped<IStoryService, StoryService>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
||||
|
||||
|
||||
15
SVSim.EmulatedEntrypoint/Services/IStoryService.cs
Normal file
15
SVSim.EmulatedEntrypoint/Services/IStoryService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public interface IStoryService
|
||||
{
|
||||
Task<SectionResponse> GetSectionsAsync(StoryApiType apiType, long viewerId);
|
||||
Task<LeaderSelectResponse> GetLeaderSelectAsync(StoryApiType apiType, int sectionId, long viewerId);
|
||||
Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId);
|
||||
Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId);
|
||||
Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId);
|
||||
Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId);
|
||||
Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId);
|
||||
}
|
||||
436
SVSim.EmulatedEntrypoint/Services/StoryService.cs
Normal file
436
SVSim.EmulatedEntrypoint/Services/StoryService.cs
Normal file
@@ -0,0 +1,436 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Repositories.Story;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class StoryService : IStoryService
|
||||
{
|
||||
private static readonly Regex BranchSuffixRx = new(@"^\d+[a-zA-Z]+", RegexOptions.Compiled);
|
||||
|
||||
private readonly IStoryMasterRepository _master;
|
||||
private readonly IViewerStoryProgressRepository _viewer;
|
||||
private readonly RewardGrantService _rewards;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IGameConfigService _configService;
|
||||
private readonly IDeckRepository _deckRepository;
|
||||
private readonly ILogger<StoryService> _logger;
|
||||
|
||||
public StoryService(
|
||||
IStoryMasterRepository master,
|
||||
IViewerStoryProgressRepository viewer,
|
||||
RewardGrantService rewards,
|
||||
SVSimDbContext db,
|
||||
IGameConfigService configService,
|
||||
IDeckRepository deckRepository,
|
||||
ILogger<StoryService> logger)
|
||||
{
|
||||
_master = master;
|
||||
_viewer = viewer;
|
||||
_rewards = rewards;
|
||||
_db = db;
|
||||
_configService = configService;
|
||||
_deckRepository = deckRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId)
|
||||
{
|
||||
var resolvedChara = charaId ?? 0;
|
||||
var chapters = await _master.GetChaptersBySectionCharaAsync(sectionId, resolvedChara);
|
||||
if (chapters.Count == 0)
|
||||
return new InfoResponse();
|
||||
|
||||
var storyIds = chapters.Select(c => c.StoryId).ToList();
|
||||
// Sequential awaits — both repos share the scoped DbContext, and EF Core forbids
|
||||
// concurrent operations on a single context. Parallel Task.WhenAll throws
|
||||
// InvalidOperationException ("A second operation was started on this context...").
|
||||
var progress = await _viewer.GetProgressForChaptersAsync(viewerId, storyIds);
|
||||
var unlocked = await _viewer.GetBranchUnlockedStoryIdsAsync(viewerId, storyIds);
|
||||
|
||||
var byChapterId = chapters.ToDictionary(c => c.ChapterId);
|
||||
var resp = new InfoResponse();
|
||||
|
||||
foreach (var c in chapters.OrderBy(x => ChapterRowNum(x.ChapterId))
|
||||
.ThenBy(x => x.ChapterId, StringComparer.Ordinal))
|
||||
{
|
||||
bool isBranchChild = BranchSuffixRx.IsMatch(c.ChapterId);
|
||||
var parent = chapters.FirstOrDefault(p =>
|
||||
!ReferenceEquals(p, c) &&
|
||||
p.NextChapterId.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(c.ChapterId));
|
||||
|
||||
bool released;
|
||||
if (parent is null) released = true;
|
||||
else if (isBranchChild) released = unlocked.Contains(c.StoryId);
|
||||
else released = (progress.TryGetValue(parent.StoryId, out var pp))
|
||||
&& (pp.IsFinish || pp.IsSkipped);
|
||||
|
||||
// Optional required_chapter_id gate
|
||||
if (!string.IsNullOrEmpty(c.RequiredChapterId) &&
|
||||
byChapterId.TryGetValue(c.RequiredChapterId, out var req))
|
||||
{
|
||||
bool reqDone = progress.TryGetValue(req.StoryId, out var rp)
|
||||
&& (rp.IsFinish || rp.IsSkipped);
|
||||
released = released && reqDone;
|
||||
}
|
||||
|
||||
var pState = progress.GetValueOrDefault(c.StoryId);
|
||||
|
||||
resp.StoryMasterList.Add(new StoryMasterEntry
|
||||
{
|
||||
StoryId = c.StoryId.ToString(),
|
||||
SectionId = c.SectionId.ToString(),
|
||||
CharaId = c.CharaId.ToString(),
|
||||
ChapterId = c.ChapterId,
|
||||
IsLock = !released,
|
||||
NextChapterId = c.NextChapterId,
|
||||
RequiredChapterId = c.RequiredChapterId ?? "",
|
||||
SelectionDisplayPosition = c.SelectionDisplayPosition ?? "",
|
||||
SelectionTextId = c.SelectionTextId ?? "",
|
||||
ShowCoordinate = c.ShowCoordinate.ToString(),
|
||||
XCoordinate = c.XCoordinate.ToString("0.#####"),
|
||||
YCoordinate = c.YCoordinate.ToString("0.#####"),
|
||||
IsCameraMovable = c.IsCameraMovable.ToString(),
|
||||
ShowSubtitles = c.ShowSubtitles.ToString(),
|
||||
BattleExists = c.BattleExists,
|
||||
EnemyCharaId = c.EnemyCharaId.ToString(),
|
||||
EnemyClass = c.EnemyClass.ToString(),
|
||||
EnemyAiId = c.EnemyAiId.ToString(),
|
||||
BgFileName = c.BgFileName,
|
||||
ChapterEffectPath = c.ChapterEffectPath ?? "",
|
||||
ChapterClearTextId = c.ChapterClearTextId ?? "",
|
||||
Battle3dFieldId = c.Battle3dFieldId.ToString(),
|
||||
BgmId = c.BgmId,
|
||||
SpecialBattleSettingId = c.SpecialBattleSettingId?.ToString() ?? "",
|
||||
ReleasePoint = c.ReleasePoint.ToString(),
|
||||
BattleSettings = c.BattleSettings.Select(b => new BattleSettingDto
|
||||
{
|
||||
DeckClassId = b.DeckClassId,
|
||||
PlayerEmotionOverride = b.PlayerEmotionOverride,
|
||||
EnemyEmotionOverride = b.EnemyEmotionOverride,
|
||||
SkinIdOverride = b.SkinIdOverride,
|
||||
Battle3dFieldIdOverride = b.Battle3dFieldIdOverride,
|
||||
BgmIdOverride = b.BgmIdOverride,
|
||||
DeckSkinIdOverride = b.DeckSkinIdOverride,
|
||||
}).ToList(),
|
||||
StoryReward = c.Rewards.Select(r => new RewardDto
|
||||
{
|
||||
RewardType = r.RewardType.ToString(),
|
||||
RewardDetailId = r.RewardDetailId.ToString(),
|
||||
RewardNumber = r.RewardNumber.ToString(),
|
||||
}).ToList(),
|
||||
IsMaintenanceChapter = c.IsMaintenanceChapter,
|
||||
IsReleased = released,
|
||||
IsSkipped = pState?.IsSkipped ?? false,
|
||||
IsFinish = pState?.IsFinish ?? false,
|
||||
IsPlayAnotherEndAppearanceAnimation = c.IsPlayAnotherEndAppearanceAnimation,
|
||||
IsReleasedAnotherEnd = c.IsReleasedAnotherEnd,
|
||||
IsSkipEnabled = c.IsSkipEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
public async Task<SectionResponse> GetSectionsAsync(StoryApiType apiType, long viewerId)
|
||||
{
|
||||
var sections = await _master.GetSectionsByFamilyAsync(apiType);
|
||||
if (sections.Count == 0) return new SectionResponse();
|
||||
|
||||
var worldIds = sections.Where(s => s.WorldId.HasValue).Select(s => s.WorldId!.Value).Distinct().ToList();
|
||||
|
||||
// Four bulk loads total — no per-(section,chara) round-trips. For a full main-story sweep
|
||||
// this is 4 queries instead of ~336. Sequential (not Task.WhenAll) because both repos
|
||||
// share the scoped DbContext — EF Core forbids concurrent operations on a single context.
|
||||
var worlds = await _master.GetWorldsForSectionsAsync(worldIds);
|
||||
var sectionIds = sections.Select(s => s.Id).ToList();
|
||||
var allChapters = await _master.GetChaptersBySectionsAsync(sectionIds);
|
||||
|
||||
var allProgress = await _viewer.GetProgressForChaptersAsync(
|
||||
viewerId, allChapters.Select(c => c.StoryId));
|
||||
|
||||
// Index chapters by (sectionId, charaId) for O(1) lookup in the rollup loop.
|
||||
var chaptersBySectionChara = allChapters
|
||||
.GroupBy(c => (c.SectionId, c.CharaId))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var charaIds = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
var resp = new SectionResponse();
|
||||
|
||||
foreach (var w in worlds)
|
||||
{
|
||||
var sectionsInWorld = sections.Where(s => s.WorldId == w.Id).OrderBy(s => s.OrderId).ToList();
|
||||
var worldDto = new SectionWorld
|
||||
{
|
||||
TitleTextId = w.TitleTextKey,
|
||||
PanelImageName = w.PanelImageName,
|
||||
RibbonText = w.RibbonText,
|
||||
};
|
||||
bool worldComplete = sectionsInWorld.Count > 0;
|
||||
foreach (var s in sectionsInWorld)
|
||||
{
|
||||
var charas = s.IsLeaderSelect ? charaIds : new[] { 0 };
|
||||
int released = 0, finished = 0, charasWithChapters = 0;
|
||||
foreach (var c in charas)
|
||||
{
|
||||
if (!chaptersBySectionChara.TryGetValue((s.Id, c), out var chapters) || chapters.Count == 0)
|
||||
continue;
|
||||
charasWithChapters++;
|
||||
int doneCount = chapters.Count(x =>
|
||||
allProgress.TryGetValue(x.StoryId, out var p) && (p.IsFinish || p.IsSkipped));
|
||||
if (doneCount > 0) released++;
|
||||
if (doneCount == chapters.Count) finished++;
|
||||
}
|
||||
// Compare against charas that actually have chapters, not the canonical 1-8 list —
|
||||
// otherwise a section missing a class would never be `IsFinished`.
|
||||
bool sectionFinished = charasWithChapters > 0 && finished == charasWithChapters;
|
||||
if (!sectionFinished) worldComplete = false;
|
||||
worldDto.SectionList.Add(new SectionEntry
|
||||
{
|
||||
SectionId = s.Id.ToString(),
|
||||
OrderId = s.OrderId,
|
||||
AllStoryOrderId = s.AllStoryOrderId.ToString(),
|
||||
Name = s.NameTextKey,
|
||||
ImageName = s.ImageName,
|
||||
IsLeaderSelect = s.IsLeaderSelect,
|
||||
BackGroundId = s.BackGroundId,
|
||||
IsFinished = sectionFinished,
|
||||
ReleasedCharaCount = released,
|
||||
FinishedCharaCount = finished,
|
||||
IsUnderMaintenance = s.IsUnderMaintenance,
|
||||
ChapterSelectType = s.ChapterSelectType.ToString(),
|
||||
StoryTypeOverwrite = s.StoryTypeOverwrite.ToString(),
|
||||
IsNew = false,
|
||||
IsPlayAnotherEndAppearanceAnimation = s.IsPlayAnotherEndAppearanceAnimation,
|
||||
});
|
||||
}
|
||||
worldDto.IsComplete = worldComplete;
|
||||
resp.WorldList[w.Id.ToString()] = worldDto;
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
public async Task<LeaderSelectResponse> GetLeaderSelectAsync(StoryApiType apiType, int sectionId, long viewerId)
|
||||
{
|
||||
// For section's chara list we use a fixed 1-8 enumeration for leader-select sections.
|
||||
// Non-leader-select sections are not expected to call this endpoint; returning leader_count=8
|
||||
// matches the client's default sentinel.
|
||||
var resp = new LeaderSelectResponse { LeaderCount = 8 };
|
||||
var charaIds = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
|
||||
// Pre-collect all story_ids across charas in this section to do one progress query.
|
||||
var perCharaChapters = new Dictionary<int, List<StoryChapter>>();
|
||||
foreach (var c in charaIds)
|
||||
{
|
||||
perCharaChapters[c] = await _master.GetChaptersBySectionCharaAsync(sectionId, c);
|
||||
}
|
||||
var allStoryIds = perCharaChapters.SelectMany(kv => kv.Value).Select(c => c.StoryId).ToList();
|
||||
var progress = await _viewer.GetProgressForChaptersAsync(viewerId, allStoryIds);
|
||||
|
||||
foreach (var c in charaIds)
|
||||
{
|
||||
var chapters = perCharaChapters[c];
|
||||
if (chapters.Count == 0)
|
||||
{
|
||||
resp.LeaderList.Add(new LeaderEntry { CharaId = c, CurrentChapter = 1 });
|
||||
continue;
|
||||
}
|
||||
int highest = 0;
|
||||
bool anySkipped = false;
|
||||
int clearedCount = 0;
|
||||
foreach (var ch in chapters)
|
||||
{
|
||||
if (progress.TryGetValue(ch.StoryId, out var p) && (p.IsFinish || p.IsSkipped))
|
||||
{
|
||||
int row = ChapterRowNum(ch.ChapterId);
|
||||
if (row > highest) highest = row;
|
||||
if (p.IsSkipped) anySkipped = true;
|
||||
clearedCount++;
|
||||
}
|
||||
}
|
||||
resp.LeaderList.Add(new LeaderEntry
|
||||
{
|
||||
CharaId = c,
|
||||
IsSkipped = anySkipped,
|
||||
IsFinished = clearedCount == chapters.Count,
|
||||
CurrentChapter = (highest == 0) ? 1 : highest + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
public async Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId)
|
||||
{
|
||||
var byFormat = await _deckRepository.GetDecksByFormats(
|
||||
viewerId, new[] { SVSim.Database.Enums.Format.Rotation, SVSim.Database.Enums.Format.Unlimited });
|
||||
return new GetDeckListResponse
|
||||
{
|
||||
UserDeckRotation = byFormat[SVSim.Database.Enums.Format.Rotation]
|
||||
.Select(d => new SVSim.EmulatedEntrypoint.Models.Dtos.UserDeck(d)).ToList(),
|
||||
UserDeckUnlimited = byFormat[SVSim.Database.Enums.Format.Unlimited]
|
||||
.Select(d => new SVSim.EmulatedEntrypoint.Models.Dtos.UserDeck(d)).ToList(),
|
||||
BuildDeckList = new List<BuildDeck>(), // v1: empty
|
||||
MaintenanceCardList = new List<long>(),
|
||||
};
|
||||
}
|
||||
public async Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId)
|
||||
{
|
||||
var resp = new StartResponse();
|
||||
for (int i = 0; i < storyIds.Length; i++)
|
||||
{
|
||||
var chapter = await _master.GetChapterByIdAsync(storyIds[i]);
|
||||
if (chapter is null)
|
||||
{
|
||||
resp[i.ToString()] = Array.Empty<object>();
|
||||
continue;
|
||||
}
|
||||
if (chapter.SpecialBattleSettingId is null)
|
||||
{
|
||||
resp[i.ToString()] = Array.Empty<object>();
|
||||
}
|
||||
else
|
||||
{
|
||||
var sbs = await _master.GetSbsByIdAsync(chapter.SpecialBattleSettingId.Value);
|
||||
if (sbs is null) { resp[i.ToString()] = Array.Empty<object>(); continue; }
|
||||
resp[i.ToString()] = new StartSlotWithSbs
|
||||
{
|
||||
SpecialBattleSetting = new SpecialBattleSettingDto
|
||||
{
|
||||
Id = sbs.Id.ToString(),
|
||||
PlayerFirstTurn = sbs.PlayerFirstTurn.ToString(),
|
||||
PlayerStartPp = sbs.PlayerStartPp.ToString(),
|
||||
EnemyStartPp = sbs.EnemyStartPp.ToString(),
|
||||
PlayerStartLife = sbs.PlayerStartLife.ToString(),
|
||||
EnemyStartLife = sbs.EnemyStartLife.ToString(),
|
||||
PlayerAttachSkill = sbs.PlayerAttachSkill,
|
||||
EnemyAttachSkill = sbs.EnemyAttachSkill,
|
||||
IdOverrideInBattleLog = sbs.IdOverrideInBattleLog,
|
||||
BanishEffectOverride = sbs.BanishEffectOverride,
|
||||
TokenDrawEffectOverride = sbs.TokenDrawEffectOverride,
|
||||
SpecialTokenDrawEffectOverride = sbs.SpecialTokenDrawEffectOverride,
|
||||
ResultSkip = sbs.ResultSkip.ToString(),
|
||||
VsEffectOverride = sbs.VsEffectOverride.ToString(),
|
||||
ClassDestroyEffectOverride = sbs.ClassDestroyEffectOverride.ToString(),
|
||||
Note = sbs.Note ?? "",
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
resp["mission_parameter"] = Array.Empty<object>();
|
||||
return resp;
|
||||
}
|
||||
public async Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
|
||||
{
|
||||
var chapter = await _master.GetChapterByIdAsync(req.StoryId);
|
||||
if (chapter is null) return new FinishResponse();
|
||||
|
||||
var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId }))
|
||||
.GetValueOrDefault(req.StoryId);
|
||||
|
||||
var resp = new FinishResponse();
|
||||
|
||||
if (req.IsPlayShape)
|
||||
{
|
||||
bool firstClear = progress is null || !progress.IsFinish;
|
||||
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: null);
|
||||
|
||||
if (firstClear)
|
||||
{
|
||||
// Load viewer with all collections RewardGrantService might mutate. Split-query
|
||||
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Sleeves)
|
||||
.Include(v => v.Emblems)
|
||||
.Include(v => v.LeaderSkins)
|
||||
.Include(v => v.Degrees)
|
||||
.Include(v => v.MyPageBackgrounds)
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
foreach (var r in chapter.Rewards)
|
||||
{
|
||||
GrantedReward granted;
|
||||
try
|
||||
{
|
||||
granted = _rewards.Apply(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"StoryService: skipping unsupported reward_type={Type} detail={Detail} num={Num} for story={StoryId}",
|
||||
r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// reward_list and story_reward_list have DIFFERENT semantics for reward_num:
|
||||
// - reward_list: post-state totals. Client (PlayerStaticData
|
||||
// .UpdateHaveUserGoodsNum) direct-assigns to in-memory
|
||||
// balances (e.g. UserRupyCount = num).
|
||||
// - story_reward_list: deltas. Client (ResultAnimationAgent
|
||||
// .HandleStoryAndMissionRewards) feeds each entry to
|
||||
// AddReward(item) which draws a "+N received" line in
|
||||
// the rewards popup.
|
||||
// Same reward_id, different reward_num. For cosmetics (binary owned/not-owned)
|
||||
// both happen to be 1, so the bug only surfaces on currency rewards.
|
||||
resp.RewardList.Add(new RewardGrant
|
||||
{
|
||||
RewardType = granted.RewardType.ToString(),
|
||||
RewardId = granted.RewardId.ToString(),
|
||||
RewardNum = granted.RewardNum.ToString(),
|
||||
});
|
||||
resp.StoryRewardList.Add(new RewardGrant
|
||||
{
|
||||
RewardType = ((int)r.RewardType).ToString(),
|
||||
RewardId = r.RewardDetailId.ToString(),
|
||||
RewardNum = r.RewardNumber.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var xp = _configService.Get<StoryConfig>().ClassXpPerClear;
|
||||
resp.GetClassExperience = xp.ToString();
|
||||
// class_experience / class_level updates would consult the viewer's per-class XP
|
||||
// table — placeholder zeros; wire to viewer.Classes[class_id] when that path exists.
|
||||
resp.ClassExperience = 0;
|
||||
resp.ClassLevel = "0";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Skip-shape: optionally unlock a branch child if selection_chapter_id is set.
|
||||
if (!string.IsNullOrEmpty(req.SelectionChapterId))
|
||||
{
|
||||
var siblings = await _master.GetChaptersBySectionCharaAsync(chapter.SectionId, chapter.CharaId);
|
||||
var child = siblings.FirstOrDefault(c => c.ChapterId == req.SelectionChapterId);
|
||||
if (child is not null)
|
||||
await _viewer.UpsertBranchUnlockAsync(viewerId, child.StoryId);
|
||||
}
|
||||
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true);
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
public async Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId)
|
||||
{
|
||||
foreach (var sid in storyIds)
|
||||
await _viewer.UpsertProgressAsync(viewerId, sid, isFinish: null, isSkipped: true);
|
||||
return new FinishResponse();
|
||||
}
|
||||
|
||||
private static int ChapterRowNum(string chapterId)
|
||||
{
|
||||
// Extract leading numeric prefix; for "12a" returns 12.
|
||||
int i = 0;
|
||||
while (i < chapterId.Length && char.IsDigit(chapterId[i])) i++;
|
||||
return int.TryParse(chapterId[..i], out int n) ? n : 0;
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,12 @@ public class GameConfigurationJsonbTests
|
||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||
var byName = rows.ToDictionary(r => r.SectionName);
|
||||
|
||||
// One row per [ConfigSection]-marked POCO (7 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule).
|
||||
// One row per [ConfigSection]-marked POCO (8 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story).
|
||||
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||
"MyRotationSchedule",
|
||||
"MyRotationSchedule", "Story",
|
||||
}));
|
||||
|
||||
var mrSchedule = JsonSerializer.Deserialize<MyRotationScheduleConfig>(byName["MyRotationSchedule"].ValueJson)!;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -49,6 +50,10 @@
|
||||
<Content Include="..\SVSim.Bootstrap\Data\test-fixtures\*.json" Link="Data\prod-captures\%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- StoryImporter integration test fixtures — copied to Story\Fixtures\ in test output. -->
|
||||
<Content Include="Story\Fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
34
SVSim.UnitTests/Story/Fixtures/importer-chapters.json
Normal file
34
SVSim.UnitTests/Story/Fixtures/importer-chapters.json
Normal file
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{"story_id": 100, "section_id": 1, "chara_id": 2, "chapter_id": "1",
|
||||
"next_chapter_id": "2", "required_chapter_id": null,
|
||||
"selection_display_position": null, "selection_text_id": null,
|
||||
"show_coordinate": 1, "x_coordinate": 100, "y_coordinate": -100,
|
||||
"is_camera_movable": 1, "show_subtitles": 0,
|
||||
"battle_exists": true, "enemy_chara_id": 500010, "enemy_class": 2, "enemy_ai_id": 2001,
|
||||
"bg_file_name": "6", "chapter_effect_path": null, "chapter_clear_text_id": null,
|
||||
"battle3dfield_id": 4, "bgm_id": "0", "special_battle_setting_id": null,
|
||||
"release_point": 0, "is_maintenance_chapter": false,
|
||||
"is_play_another_end_appearance_animation": false, "is_released_another_end": false,
|
||||
"is_skip_enabled": true,
|
||||
"battle_settings": [{"deck_class_id": 2, "player_emotion_override": 0,
|
||||
"enemy_emotion_override": 0, "skin_id_override": 0, "battle3dfield_id_override": 0,
|
||||
"bgm_id_override": 0, "deck_skin_id_override": 0}],
|
||||
"story_reward": [{"reward_type": 5, "reward_detail_id": 100222010, "reward_number": 3}],
|
||||
"sub_chapters": []},
|
||||
{"story_id": 101, "section_id": 1, "chara_id": 2, "chapter_id": "2",
|
||||
"next_chapter_id": "3", "required_chapter_id": null,
|
||||
"selection_display_position": null, "selection_text_id": null,
|
||||
"show_coordinate": 1, "x_coordinate": 200, "y_coordinate": -100,
|
||||
"is_camera_movable": 1, "show_subtitles": 0,
|
||||
"battle_exists": true, "enemy_chara_id": 6, "enemy_class": 6, "enemy_ai_id": 2002,
|
||||
"bg_file_name": "4", "chapter_effect_path": null, "chapter_clear_text_id": null,
|
||||
"battle3dfield_id": 2, "bgm_id": "0", "special_battle_setting_id": 8,
|
||||
"release_point": 0, "is_maintenance_chapter": false,
|
||||
"is_play_another_end_appearance_animation": false, "is_released_another_end": false,
|
||||
"is_skip_enabled": true,
|
||||
"battle_settings": [{"deck_class_id": 2, "player_emotion_override": 0,
|
||||
"enemy_emotion_override": 0, "skin_id_override": 0, "battle3dfield_id_override": 0,
|
||||
"bgm_id_override": 0, "deck_skin_id_override": 0}],
|
||||
"story_reward": [{"reward_type": 1, "reward_detail_id": 0, "reward_number": 20}],
|
||||
"sub_chapters": []}
|
||||
]
|
||||
9
SVSim.UnitTests/Story/Fixtures/importer-sbs.json
Normal file
9
SVSim.UnitTests/Story/Fixtures/importer-sbs.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"id": 8, "player_first_turn": 0, "player_start_pp": 0, "enemy_start_pp": 0,
|
||||
"player_start_life": 20, "enemy_start_life": 10,
|
||||
"player_attach_skill": "", "enemy_attach_skill": "",
|
||||
"id_override_in_battle_log": "", "banish_effect_override": "",
|
||||
"token_draw_effect_override": "", "special_token_draw_effect_override": "",
|
||||
"result_skip": 0, "vs_effect_override": 0, "class_destroy_effect_override": 0,
|
||||
"note": "test sbs"}
|
||||
]
|
||||
6
SVSim.UnitTests/Story/Fixtures/importer-sections.json
Normal file
6
SVSim.UnitTests/Story/Fixtures/importer-sections.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"id": 1, "world_id": 1, "story_api_type": "Main", "order_id": 1, "all_story_order_id": 1,
|
||||
"name_text_key": "section_1", "image_name": "btn_1", "is_leader_select": true,
|
||||
"back_ground_id": 1, "chapter_select_type": 1, "story_type_overwrite": 1,
|
||||
"is_under_maintenance": false, "is_play_another_end_appearance_animation": false}
|
||||
]
|
||||
3
SVSim.UnitTests/Story/Fixtures/importer-worlds.json
Normal file
3
SVSim.UnitTests/Story/Fixtures/importer-worlds.json
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"id": 1, "title_text_key": "world_1", "panel_image_name": "panel_1", "ribbon_text": ""}
|
||||
]
|
||||
12
SVSim.UnitTests/Story/Fixtures/snapshot-finish-response.json
Normal file
12
SVSim.UnitTests/Story/Fixtures/snapshot-finish-response.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"get_class_experience":"0","class_experience":0,"class_level":"0",
|
||||
"achieved_info":{},"reward_list":[],"story_reward_list":[],
|
||||
"quest":{"is_display_badge":false},
|
||||
"story_notification":{"is_display_badge":false},
|
||||
"basic_puzzle":{"is_display_badge":false},
|
||||
"shop_notification":{"card_pack":false,"build_deck":false,"sleeve":false,"leader_skin":false},
|
||||
"receive_friend_apply_count":0,
|
||||
"gathering_info":{"has_invite":0,"is_entry":0},
|
||||
"competition_info":{"is_competition_period":false},
|
||||
"is_available_colosseum_free_entry":false
|
||||
}
|
||||
18
SVSim.UnitTests/Story/Fixtures/snapshot-info-response.json
Normal file
18
SVSim.UnitTests/Story/Fixtures/snapshot-info-response.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"story_master_list":[
|
||||
{"story_id":"100","section_id":"1","chara_id":"2","chapter_id":"1","is_lock":false,
|
||||
"next_chapter_id":"2","required_chapter_id":"","selection_display_position":"",
|
||||
"selection_text_id":"","show_coordinate":"1","x_coordinate":"100","y_coordinate":"-100",
|
||||
"is_camera_ movable":"1","show_subtitles":"0","battle_exists":true,
|
||||
"enemy_chara_id":"500010","enemy_class":"2","enemy_ai_id":"2001","bg_file_name":"6",
|
||||
"chapter_effect_path":"","chapter_clear_text_id":"","battle3dfield_id":"4","bgm_id":"0",
|
||||
"special_battle_setting_id":"","release_point":"0",
|
||||
"battle_settings":[{"deck_class_id":2,"player_emotion_override":0,"enemy_emotion_override":0,
|
||||
"skin_id_override":0,"battle3dfield_id_override":0,"bgm_id_override":0,"deck_skin_id_override":0}],
|
||||
"story_reward":[{"reward_type":"1","reward_detail_id":"0","reward_number":"100"}],
|
||||
"is_maintenance_chapter":false,"is_released":true,"is_skipped":false,"is_finish":false,
|
||||
"unlock_text":"","is_play_another_end_appearance_animation":false,
|
||||
"is_released_another_end":false,"is_skip_enabled":true}
|
||||
],
|
||||
"maintenance_card_list":[]
|
||||
}
|
||||
68
SVSim.UnitTests/Story/RoutingSmokeTests.Story.cs
Normal file
68
SVSim.UnitTests/Story/RoutingSmokeTests.Story.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using SVSim.Database;
|
||||
using SVSim.EmulatedEntrypoint;
|
||||
|
||||
namespace SVSim.UnitTests.Story;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke tests for the 21 story URLs. We assert the framework matched the route
|
||||
/// (status != 404). Auth-required routes return 401, which is fine — that still means routing matched.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class RoutingSmokeTestsStory
|
||||
{
|
||||
private sealed class TestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<SVSimDbContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
services.AddDbContext<SVSimDbContext>(opt => opt.UseInMemoryDatabase("RoutingSmokeStory"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private const string ValidBaseRequestJson =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||
|
||||
[TestCase("/story/section")]
|
||||
[TestCase("/main_story/section")]
|
||||
[TestCase("/limited_story/section")]
|
||||
[TestCase("/event_story/section")]
|
||||
[TestCase("/main_story/leader_select")]
|
||||
[TestCase("/limited_story/leader_select")]
|
||||
[TestCase("/event_story/leader_select")]
|
||||
[TestCase("/main_story/info")]
|
||||
[TestCase("/limited_story/info")]
|
||||
[TestCase("/event_story/info")]
|
||||
[TestCase("/main_story/get_deck_list")]
|
||||
[TestCase("/event_story/get_deck_list")]
|
||||
[TestCase("/main_story/start")]
|
||||
[TestCase("/limited_story/start")]
|
||||
[TestCase("/event_story/start")]
|
||||
[TestCase("/main_story/finish")]
|
||||
[TestCase("/limited_story/finish")]
|
||||
[TestCase("/event_story/finish")]
|
||||
[TestCase("/main_story/all_finish")]
|
||||
[TestCase("/limited_story/all_finish")]
|
||||
[TestCase("/event_story/all_finish")]
|
||||
public async Task Story_route_resolves(string path)
|
||||
{
|
||||
using var factory = new TestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync(path,
|
||||
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound),
|
||||
$"Route {path} did not match — route registration broken.");
|
||||
}
|
||||
}
|
||||
58
SVSim.UnitTests/Story/StoryImporterTests.cs
Normal file
58
SVSim.UnitTests/Story/StoryImporterTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
|
||||
namespace SVSim.UnitTests.Story;
|
||||
|
||||
[TestFixture]
|
||||
public class StoryImporterTests
|
||||
{
|
||||
private static string FixturesDir =>
|
||||
Path.Combine(AppContext.BaseDirectory, "Story", "Fixtures");
|
||||
|
||||
private static SVSimDbContext NewInMemoryContext(string name)
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<SVSimDbContext>()
|
||||
.UseInMemoryDatabase(name)
|
||||
.Options;
|
||||
return new SVSimDbContext(NullLogger<SVSimDbContext>.Instance, opts);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_inserts_worlds_sections_chapters_sbs_from_fixtures()
|
||||
{
|
||||
await using var ctx = NewInMemoryContext(nameof(ImportAsync_inserts_worlds_sections_chapters_sbs_from_fixtures));
|
||||
|
||||
var importer = new StoryImporter();
|
||||
await importer.ImportAsync(ctx, FixturesDir);
|
||||
|
||||
Assert.That(await ctx.StoryWorlds.CountAsync(), Is.EqualTo(1));
|
||||
Assert.That(await ctx.StorySections.CountAsync(), Is.EqualTo(1));
|
||||
Assert.That(await ctx.StoryChapters.CountAsync(), Is.EqualTo(2));
|
||||
Assert.That(await ctx.SpecialBattleSettings.CountAsync(), Is.EqualTo(1));
|
||||
|
||||
var chapter2 = await ctx.StoryChapters
|
||||
.Include(c => c.BattleSettings).Include(c => c.Rewards)
|
||||
.FirstAsync(c => c.StoryId == 101);
|
||||
Assert.That(chapter2.SpecialBattleSettingId, Is.EqualTo(8));
|
||||
Assert.That(chapter2.Rewards.Count, Is.EqualTo(1));
|
||||
Assert.That(chapter2.BattleSettings.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_is_idempotent_no_changes_on_rerun()
|
||||
{
|
||||
await using var ctx = NewInMemoryContext(nameof(ImportAsync_is_idempotent_no_changes_on_rerun));
|
||||
|
||||
var importer = new StoryImporter();
|
||||
await importer.ImportAsync(ctx, FixturesDir);
|
||||
var afterFirst = await ctx.StoryChapters.CountAsync();
|
||||
|
||||
await importer.ImportAsync(ctx, FixturesDir);
|
||||
var afterSecond = await ctx.StoryChapters.CountAsync();
|
||||
|
||||
Assert.That(afterSecond, Is.EqualTo(afterFirst));
|
||||
}
|
||||
}
|
||||
423
SVSim.UnitTests/Story/StoryServiceTests.cs
Normal file
423
SVSim.UnitTests/Story/StoryServiceTests.cs
Normal file
@@ -0,0 +1,423 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Story;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Story;
|
||||
|
||||
[TestFixture]
|
||||
public class StoryServiceTests
|
||||
{
|
||||
private Mock<IStoryMasterRepository> _master = null!;
|
||||
private Mock<IViewerStoryProgressRepository> _viewer = null!;
|
||||
private StoryService _service = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_master = new Mock<IStoryMasterRepository>();
|
||||
_viewer = new Mock<IViewerStoryProgressRepository>();
|
||||
// Non-reward tests never exercise the DB/reward path; use a stub InMemory context.
|
||||
var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp));
|
||||
var rewards = new RewardGrantService(db);
|
||||
_service = new StoryService(
|
||||
_master.Object, _viewer.Object,
|
||||
rewards: rewards,
|
||||
db: db,
|
||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||
logger: NullLogger<StoryService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="StoryService"/> backed by a real <see cref="SVSimDbContext"/> from
|
||||
/// <paramref name="factory"/>, seeds a viewer with RedEther reset to 0, and returns the
|
||||
/// service + viewer's actual ID.
|
||||
/// The caller owns the factory lifetime; keep it alive for post-call assertions.
|
||||
/// </summary>
|
||||
private StoryService NewServiceWithSeededViewer(
|
||||
SVSimTestFactory factory,
|
||||
out IServiceScope scope,
|
||||
out long viewerId)
|
||||
{
|
||||
viewerId = factory.SeedViewerAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Reset RedEther to 0 so tests can assert literal post-state totals (spec requirement).
|
||||
var seedId = viewerId;
|
||||
using (var resetScope = factory.Services.CreateScope())
|
||||
{
|
||||
var resetDb = resetScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = resetDb.Viewers.First(x => x.Id == seedId);
|
||||
v.Currency.RedEther = 0;
|
||||
resetDb.SaveChanges();
|
||||
}
|
||||
|
||||
scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var rewards = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
|
||||
|
||||
return new StoryService(
|
||||
_master.Object,
|
||||
_viewer.Object,
|
||||
rewards: rewards,
|
||||
db: db,
|
||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||
logger: NullLogger<StoryService>.Instance);
|
||||
}
|
||||
|
||||
private static StoryChapter Ch(int storyId, int section, int chara, string chapId, string nextId,
|
||||
bool battle = true, int? sbsId = null) =>
|
||||
new() { StoryId = storyId, SectionId = section, CharaId = chara,
|
||||
ChapterId = chapId, NextChapterId = nextId, BattleExists = battle,
|
||||
SpecialBattleSettingId = sbsId, IsSkipEnabled = true };
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_chapter1_always_released_chapter2_locked_when_no_progress()
|
||||
{
|
||||
var chapters = new List<StoryChapter> {
|
||||
Ch(100, 1, 2, "1", "2"),
|
||||
Ch(101, 1, 2, "2", "3"),
|
||||
};
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(chapters);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 1, 2, viewerId: 7L);
|
||||
|
||||
var ch1 = resp.StoryMasterList.Single(c => c.ChapterId == "1");
|
||||
var ch2 = resp.StoryMasterList.Single(c => c.ChapterId == "2");
|
||||
Assert.That(ch1.IsReleased, Is.True);
|
||||
Assert.That(ch2.IsReleased, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_chapter2_released_after_chapter1_finished()
|
||||
{
|
||||
var chapters = new List<StoryChapter> {
|
||||
Ch(100, 1, 2, "1", "2"),
|
||||
Ch(101, 1, 2, "2", "3"),
|
||||
};
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(chapters);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 100, new ViewerStoryProgress { ViewerId = 7, StoryId = 100, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 1, 2, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "2").IsReleased, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_chapter2_released_after_skip_clear_too()
|
||||
{
|
||||
var chapters = new List<StoryChapter> {
|
||||
Ch(100, 1, 2, "1", "2"), Ch(101, 1, 2, "2", "3"),
|
||||
};
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(chapters);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 100, new ViewerStoryProgress { StoryId = 100, IsSkipped = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 1, 2, viewerId: 7L);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "2").IsReleased, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_branch_children_locked_without_explicit_unlock()
|
||||
{
|
||||
var chapters = new List<StoryChapter> {
|
||||
Ch(200, 17, 500901, "2", "3a 3b 3c"),
|
||||
Ch(201, 17, 500901, "3a", "4a"),
|
||||
Ch(202, 17, 500901, "3b", "4b"),
|
||||
};
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901)).ReturnsAsync(chapters);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsReleased, Is.False);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsReleased, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetInfoAsync_branch_child_released_when_unlock_exists()
|
||||
{
|
||||
var chapters = new List<StoryChapter> {
|
||||
Ch(200, 17, 500901, "2", "3a 3b 3c"),
|
||||
Ch(201, 17, 500901, "3a", "4a"),
|
||||
Ch(202, 17, 500901, "3b", "4b"),
|
||||
};
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901)).ReturnsAsync(chapters);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int> { 201 }); // only 3a unlocked
|
||||
|
||||
var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsReleased, Is.True);
|
||||
Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsReleased, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLeaderSelectAsync_untouched_chara_has_current_chapter_1()
|
||||
{
|
||||
_master.Setup(m => m.GetSectionsByFamilyAsync(StoryApiType.Main))
|
||||
.ReturnsAsync(new List<StorySection> { new() { Id = 1, IsLeaderSelect = true } });
|
||||
foreach (int chara in new[] { 1, 2, 3, 4, 5, 6, 7, 8 })
|
||||
{
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(1, chara))
|
||||
.ReturnsAsync(new List<StoryChapter> { Ch(100 + chara, 1, chara, "1", "2") });
|
||||
}
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
var resp = await _service.GetLeaderSelectAsync(StoryApiType.Main, 1, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.LeaderList, Has.Count.EqualTo(8));
|
||||
Assert.That(resp.LeaderList.All(l => l.CurrentChapter == 1));
|
||||
Assert.That(resp.LeaderCount, Is.EqualTo(8));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLeaderSelectAsync_after_clearing_chapter5_current_chapter_is_6()
|
||||
{
|
||||
_master.Setup(m => m.GetSectionsByFamilyAsync(StoryApiType.Main))
|
||||
.ReturnsAsync(new List<StorySection> { new() { Id = 1, IsLeaderSelect = true } });
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(new List<StoryChapter> {
|
||||
Ch(101, 1, 2, "1", "2"), Ch(102, 1, 2, "2", "3"), Ch(103, 1, 2, "3", "4"),
|
||||
Ch(104, 1, 2, "4", "5"), Ch(105, 1, 2, "5", "6"), Ch(106, 1, 2, "6", "0"),
|
||||
});
|
||||
foreach (int chara in new[] { 1, 3, 4, 5, 6, 7, 8 })
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(1, chara)).ReturnsAsync(new List<StoryChapter>());
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 101, new ViewerStoryProgress { StoryId = 101, IsFinish = true } },
|
||||
{ 102, new ViewerStoryProgress { StoryId = 102, IsFinish = true } },
|
||||
{ 103, new ViewerStoryProgress { StoryId = 103, IsFinish = true } },
|
||||
{ 104, new ViewerStoryProgress { StoryId = 104, IsFinish = true } },
|
||||
{ 105, new ViewerStoryProgress { StoryId = 105, IsFinish = true } },
|
||||
});
|
||||
|
||||
var resp = await _service.GetLeaderSelectAsync(StoryApiType.Main, 1, viewerId: 7L);
|
||||
|
||||
var chara2 = resp.LeaderList.Single(l => l.CharaId == 2);
|
||||
Assert.That(chara2.CurrentChapter, Is.EqualTo(6));
|
||||
Assert.That(chara2.IsFinished, Is.False); // chapter 6 not done yet
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartAsync_returns_sbs_payload_for_chapter_with_sbs_id()
|
||||
{
|
||||
var chapter = Ch(101, 1, 2, "2", "3", sbsId: 8);
|
||||
var sbs = new SpecialBattleSetting { Id = 8, PlayerStartLife = 20, EnemyStartLife = 10,
|
||||
Note = "Disaster Tree ch2&3" };
|
||||
_master.Setup(m => m.GetChapterByIdAsync(101)).ReturnsAsync(chapter);
|
||||
_master.Setup(m => m.GetSbsByIdAsync(8)).ReturnsAsync(sbs);
|
||||
|
||||
var resp = await _service.StartAsync(StoryApiType.Main, new[] { 101 }, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.ContainsKey("0"), Is.True);
|
||||
var slot = (StartSlotWithSbs)resp["0"];
|
||||
Assert.That(slot.SpecialBattleSetting.Id, Is.EqualTo("8"));
|
||||
Assert.That(slot.SpecialBattleSetting.EnemyStartLife, Is.EqualTo("10"));
|
||||
Assert.That(resp.ContainsKey("mission_parameter"), Is.True);
|
||||
Assert.That(((Array)resp["mission_parameter"]).Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartAsync_returns_empty_slot_for_chapter_without_sbs_id()
|
||||
{
|
||||
var chapter = Ch(100, 1, 2, "1", "2", sbsId: null);
|
||||
_master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter);
|
||||
|
||||
var resp = await _service.StartAsync(StoryApiType.Main, new[] { 100 }, viewerId: 7L);
|
||||
|
||||
Assert.That(resp["0"], Is.InstanceOf<Array>());
|
||||
Assert.That(((Array)resp["0"]).Length, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_skip_shape_sets_isSkipped_and_grants_nothing()
|
||||
{
|
||||
var chapter = Ch(100, 1, 2, "1", "2");
|
||||
chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardNumber = 20 });
|
||||
_master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
var req = new FinishRequest {
|
||||
StoryId = 100, IsFinish = 1,
|
||||
SelectionChapterId = null,
|
||||
IsSelectAnotherEnd = false,
|
||||
ClassId = null, // play-shape absence → skip
|
||||
};
|
||||
|
||||
var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.RewardList, Is.Empty);
|
||||
Assert.That(resp.StoryRewardList, Is.Empty);
|
||||
Assert.That(resp.GetClassExperience, Is.EqualTo("0"));
|
||||
_viewer.Verify(v => v.UpsertProgressAsync(7L, 100, null, true), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_skip_shape_with_selection_unlocks_branch()
|
||||
{
|
||||
var parent = Ch(200, 17, 500901, "2", "3a 3b 3c");
|
||||
var branch3b = Ch(202, 17, 500901, "3b", "4b");
|
||||
_master.Setup(m => m.GetChapterByIdAsync(200)).ReturnsAsync(parent);
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901))
|
||||
.ReturnsAsync(new List<StoryChapter> { parent, branch3b });
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
var req = new FinishRequest {
|
||||
StoryId = 200, IsFinish = 1,
|
||||
SelectionChapterId = "3b",
|
||||
IsSelectAnotherEnd = false,
|
||||
};
|
||||
|
||||
await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L);
|
||||
|
||||
_viewer.Verify(v => v.UpsertBranchUnlockAsync(7L, 202), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_play_shape_first_clear_grants_rewards_and_xp()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId);
|
||||
using (scope)
|
||||
{
|
||||
var chapter = Ch(100, 1, 2, "1", "2");
|
||||
chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardDetailId = 0, RewardNumber = 100 });
|
||||
_master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
var req = new FinishRequest {
|
||||
StoryId = 100, IsFinish = 1, ClassId = 2,
|
||||
EvolveCount = 0, TotalTurn = 5, DeckNo = 1,
|
||||
UseBuildDeck = 0, DeckFormat = 1, Mission = new(),
|
||||
RecoveryData = null,
|
||||
};
|
||||
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
// Viewer started at RedEther=0; grant of 100 → post-state total = 100.
|
||||
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
||||
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||
Assert.That(resp.GetClassExperience, Is.EqualTo("200"));
|
||||
_viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once);
|
||||
|
||||
// Confirm currency persisted: fetch fresh viewer from a new scope.
|
||||
using var verifyScope = factory.Services.CreateScope();
|
||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_play_shape_replay_does_not_double_grant()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId);
|
||||
using (scope)
|
||||
{
|
||||
var chapter = Ch(100, 1, 2, "1", "2");
|
||||
chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardDetailId = 0, RewardNumber = 100 });
|
||||
_master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter);
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 100, new ViewerStoryProgress { StoryId = 100, IsFinish = true } } });
|
||||
|
||||
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
Assert.That(resp.RewardList, Is.Empty);
|
||||
|
||||
// Currency must not have changed from its seed value of 0.
|
||||
using var verifyScope = factory.Services.CreateScope();
|
||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(0UL));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_battle_after_skip_still_grants_rewards()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId);
|
||||
using (scope)
|
||||
{
|
||||
var chapter = Ch(100, 1, 2, "1", "2");
|
||||
chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardDetailId = 0, RewardNumber = 100 });
|
||||
_master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter);
|
||||
// Previously skipped but never finished — should be treated as first clear.
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 100, new ViewerStoryProgress { StoryId = 100, IsFinish = false, IsSkipped = true } } });
|
||||
|
||||
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
||||
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||
|
||||
using var verifyScope = factory.Services.CreateScope();
|
||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static class StoryServiceTestHelpers
|
||||
{
|
||||
public static SVSim.Database.Services.IGameConfigService NewConfigService()
|
||||
{
|
||||
var mock = new Mock<SVSim.Database.Services.IGameConfigService>();
|
||||
mock.Setup(s => s.Get<SVSim.Database.Models.Config.StoryConfig>())
|
||||
.Returns(new SVSim.Database.Models.Config.StoryConfig { ClassXpPerClear = 200 });
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a minimal <see cref="SVSimDbContext"/> backed by the EF InMemory provider.
|
||||
/// Safe for non-reward tests that never actually query the DB.
|
||||
/// Each call should use a unique <paramref name="dbName"/> to prevent test bleed-through.
|
||||
/// </summary>
|
||||
public static SVSimDbContext NewInMemoryDb(string dbName)
|
||||
{
|
||||
var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder<SVSimDbContext>()
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.Options;
|
||||
return new SVSimDbContext(
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<SVSimDbContext>.Instance,
|
||||
options);
|
||||
}
|
||||
}
|
||||
91
SVSim.UnitTests/Story/StoryWireShapeTests.cs
Normal file
91
SVSim.UnitTests/Story/StoryWireShapeTests.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
namespace SVSim.UnitTests.Story;
|
||||
|
||||
[TestFixture]
|
||||
public class StoryWireShapeTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Opts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void InfoResponse_serializes_to_expected_shape()
|
||||
{
|
||||
var dto = new InfoResponse
|
||||
{
|
||||
StoryMasterList = new()
|
||||
{
|
||||
new StoryMasterEntry
|
||||
{
|
||||
StoryId = "100", SectionId = "1", CharaId = "2", ChapterId = "1",
|
||||
IsLock = false, NextChapterId = "2",
|
||||
ShowCoordinate = "1", XCoordinate = "100", YCoordinate = "-100",
|
||||
IsCameraMovable = "1", ShowSubtitles = "0",
|
||||
BattleExists = true, EnemyCharaId = "500010", EnemyClass = "2",
|
||||
EnemyAiId = "2001", BgFileName = "6",
|
||||
Battle3dFieldId = "4", BgmId = "0", ReleasePoint = "0",
|
||||
BattleSettings = new() { new BattleSettingDto { DeckClassId = 2 } },
|
||||
StoryReward = new() { new RewardDto {
|
||||
RewardType = "1", RewardDetailId = "0", RewardNumber = "100" } },
|
||||
IsReleased = true, IsSkipEnabled = true,
|
||||
}
|
||||
}
|
||||
};
|
||||
var actual = JsonSerializer.Serialize(dto, Opts);
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Story", "Fixtures",
|
||||
"snapshot-info-response.json");
|
||||
var expectedJson = File.ReadAllText(expectedPath);
|
||||
|
||||
AssertJsonEquivalent(actual, expectedJson);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FinishResponse_default_serializes_to_expected_shape()
|
||||
{
|
||||
var dto = new FinishResponse();
|
||||
var actual = JsonSerializer.Serialize(dto, Opts);
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Story", "Fixtures",
|
||||
"snapshot-finish-response.json");
|
||||
var expectedJson = File.ReadAllText(expectedPath);
|
||||
AssertJsonEquivalent(actual, expectedJson);
|
||||
}
|
||||
|
||||
private static void AssertJsonEquivalent(string actualJson, string expectedJson)
|
||||
{
|
||||
var a = JsonDocument.Parse(actualJson).RootElement;
|
||||
var e = JsonDocument.Parse(expectedJson).RootElement;
|
||||
Assert.That(JsonDeepEquals(a, e), Is.True,
|
||||
$"JSON mismatch.\nExpected: {expectedJson}\nActual: {actualJson}");
|
||||
}
|
||||
|
||||
private static bool JsonDeepEquals(JsonElement a, JsonElement b)
|
||||
{
|
||||
if (a.ValueKind != b.ValueKind) return false;
|
||||
switch (a.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var ap = a.EnumerateObject().OrderBy(p => p.Name).ToList();
|
||||
var bp = b.EnumerateObject().OrderBy(p => p.Name).ToList();
|
||||
if (ap.Count != bp.Count) return false;
|
||||
for (int i = 0; i < ap.Count; i++)
|
||||
if (ap[i].Name != bp[i].Name || !JsonDeepEquals(ap[i].Value, bp[i].Value))
|
||||
return false;
|
||||
return true;
|
||||
case JsonValueKind.Array:
|
||||
var ae = a.EnumerateArray().ToList();
|
||||
var be = b.EnumerateArray().ToList();
|
||||
if (ae.Count != be.Count) return false;
|
||||
for (int i = 0; i < ae.Count; i++)
|
||||
if (!JsonDeepEquals(ae[i], be[i])) return false;
|
||||
return true;
|
||||
default:
|
||||
return a.GetRawText() == b.GetRawText();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user