Update menu_base implementation, assets and documentation

This commit is contained in:
cthangover committed 2026-10-31 15:28:53 +03:00
1 parent 57450def43
commit ba177280a5
13 files changed
+275 -74

No files matched your search

-4
View File
@@ -32,10 +32,6 @@ menu/save/hint = Click a slot to save the game; a used slot is overwritten after
menu/play/hint_warming = The games' mods are being prepared in the background — starting will be quick afterwards
menu/play/hint_ready = The games' mods are ready, starting takes a few seconds
menu/load/legacy_group = Saves without a game
menu/load/legacy_title = Save without a game
menu/load/legacy_text = This save was made before game collections existed. Choose the game it belongs to — the file moves into that game's saves.
menu/load/legacy_ok = Move and load
menu/load/legacy_no_games = There is no ready game to assign the save to.
menu/load/diff_title = The game has changed
menu/load/diff_intro = The game's composition changed since this save.
menu/load/diff_added = Added (starts from scratch):
-4
View File
@@ -32,10 +32,6 @@ menu/save/hint = Haz clic en una ranura para guardar; una ranura ocupada se sobr
menu/play/hint_warming = Los mods de los juegos se preparan en segundo plano: después el arranque será rápido
menu/play/hint_ready = Los mods de los juegos están listos, el arranque tarda unos segundos
menu/load/legacy_group = Partidas sin juego
menu/load/legacy_title = Partida sin juego
menu/load/legacy_text = Esta partida se guardó antes de que existieran las colecciones de juegos. Elige el juego al que pertenece: el archivo pasará a las partidas de ese juego.
menu/load/legacy_ok = Mover y cargar
menu/load/legacy_no_games = No hay ningún juego listo al que asignar la partida.
menu/load/diff_title = El juego ha cambiado
menu/load/diff_intro = La composición del juego ha cambiado desde esta partida.
menu/load/diff_added = Añadido (empieza de cero):
-4
View File
@@ -32,10 +32,6 @@ menu/save/hint = スロットをクリックするとセーブします。使用
menu/play/hint_warming = ゲームの MOD をバックグラウンドで準備しています——その後の起動は速くなります
menu/play/hint_ready = ゲームの MOD は準備できています。起動は数秒です
menu/load/legacy_group = ゲームのないセーブ
menu/load/legacy_title = ゲームのないセーブ
menu/load/legacy_text = このセーブはゲームのコレクションができる前に作られました。どのゲームのものか選んでください——ファイルはそのゲームのセーブに移されます。
menu/load/legacy_ok = 移動してロード
menu/load/legacy_no_games = このセーブを割り当てられる準備済みのゲームがありません。
menu/load/diff_title = ゲームが変わりました
menu/load/diff_intro = このセーブの後で、ゲームの構成が変わりました。
menu/load/diff_added = 追加(最初から):
-4
View File
@@ -32,10 +32,6 @@ menu/save/hint = 슬롯을 클릭하면 저장합니다. 사용 중인 슬롯은
menu/play/hint_warming = 게임의 모드를 백그라운드에서 준비하고 있습니다 — 그 뒤에는 빠르게 시작됩니다
menu/play/hint_ready = 게임의 모드가 준비되었습니다. 시작은 몇 초면 됩니다
menu/load/legacy_group = 게임이 없는 저장 파일
menu/load/legacy_title = 게임이 없는 저장 파일
menu/load/legacy_text = 이 저장 파일은 게임 컬렉션이 생기기 전에 만들어졌습니다. 어느 게임의 것인지 고르세요 — 파일이 그 게임의 저장 파일로 옮겨집니다.
menu/load/legacy_ok = 옮기고 불러오기
menu/load/legacy_no_games = 이 저장 파일을 배정할 준비된 게임이 없습니다.
menu/load/diff_title = 게임이 바뀌었습니다
menu/load/diff_intro = 이 저장 이후 게임 구성이 바뀌었습니다.
menu/load/diff_added = 추가됨 (처음부터 시작):
-4
View File
@@ -32,10 +32,6 @@ menu/save/hint = Нажмите на слот, чтобы сохранить и
menu/play/hint_warming = Моды игр готовятся в фоне — запуск после этого будет быстрым
menu/play/hint_ready = Моды игр готовы, запуск займёт несколько секунд
menu/load/legacy_group = Сохранения без игры
menu/load/legacy_title = Сохранение без игры
menu/load/legacy_text = Это сохранение сделано до появления игр-коллекций. Выберите игру, к которой оно относится, — файл переедет в её сохранения.
menu/load/legacy_ok = Перенести и загрузить
menu/load/legacy_no_games = Нет готовых игр, к которым можно отнести сохранение.
menu/load/diff_title = Состав игры изменился
menu/load/diff_intro = С момента сохранения состав игры изменился.
menu/load/diff_added = Добавлено (начнёт с нуля):
-4
View File
@@ -32,10 +32,6 @@ menu/save/hint = Nhấp vào một ô để lưu game; ô đã dùng sẽ bị g
menu/play/hint_warming = Mod của các game đang được chuẩn bị ở nền — sau đó khởi động sẽ nhanh
menu/play/hint_ready = Mod của các game đã sẵn sàng, khởi động mất vài giây
menu/load/legacy_group = Bản lưu không có game
menu/load/legacy_title = Bản lưu không có game
menu/load/legacy_text = Bản lưu này được tạo trước khi có bộ sưu tập game. Hãy chọn game mà nó thuộc về — tệp sẽ chuyển vào các bản lưu của game đó.
menu/load/legacy_ok = Chuyển và tải
menu/load/legacy_no_games = Không có game sẵn sàng nào để gán bản lưu.
menu/load/diff_title = Game đã thay đổi
menu/load/diff_intro = Thành phần của game đã thay đổi kể từ bản lưu này.
menu/load/diff_added = Đã thêm (bắt đầu từ đầu):
-4
View File
@@ -32,10 +32,6 @@ menu/save/hint = 点击栏位即可存档;已占用的栏位在确认后会被
menu/play/hint_warming = 游戏的模组正在后台准备——之后启动会很快
menu/play/hint_ready = 游戏的模组已就绪,启动只需几秒
menu/load/legacy_group = 没有所属游戏的存档
menu/load/legacy_title = 没有所属游戏的存档
menu/load/legacy_text = 这个存档是在游戏合集出现之前创建的。请选择它所属的游戏——文件会移到该游戏的存档中。
menu/load/legacy_ok = 移动并读取
menu/load/legacy_no_games = 没有可以分配此存档的就绪游戏。
menu/load/diff_title = 游戏已改变
menu/load/diff_intro = 自这个存档以来,游戏的组成发生了变化。
menu/load/diff_added = 新增(从头开始):
+2 -1
View File
@@ -15,6 +15,7 @@
{"id": "new_game", "activate": ["--itest=new_game"]},
{"id": "hot_reload", "activate": ["--itest=hot_reload"]},
{"id": "collection_edit", "activate": ["--itest=collection_edit"]},
{"id": "display_settings", "activate": ["--itest=display_settings"]}
{"id": "display_settings", "activate": ["--itest=display_settings"]},
{"id": "resettables", "activate": ["--itest=resettables"]}
]
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 188 KiB

+3 -45
View File
@@ -6,6 +6,7 @@ using Cthangover.Core.Games;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.BaseMenu
@@ -56,7 +57,8 @@ namespace Cthangover.BaseMenu
return;
}
ChooseGameForSave(host, fileName);
// Saves without a game are not offered by the menu (no adoption of old saves).
GameLogger.Log("SAVE", $"Save '{fileName}' has no game and cannot be loaded from the menu", LogLevel.Error);
return;
}
@@ -127,50 +129,6 @@ namespace Cthangover.BaseMenu
? $"{info.DisplayTitle} ({modId})"
: modId;
/// <summary>A save made before games existed: the player picks the game it belongs to; it moves there.</summary>
private static void ChooseGameForSave(Node host, string fileName)
{
var games = GameCatalog.Games().Where(g => g.IsReady).ToList();
if (games.Count == 0)
{
Confirm(host, TranslationServer.Translate("menu/load/legacy_title"),
TranslationServer.Translate("menu/load/legacy_no_games"), null, null);
return;
}
var select = new OptionButton();
foreach (var game in games)
select.AddItem(game.Name ?? game.Collection.Id);
var content = new VBoxContainer();
content.AddChild(new Label
{
Text = TranslationServer.Translate("menu/load/legacy_text"),
AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(420, 0),
});
content.AddChild(select);
var dialog = new ConfirmationDialog
{
Title = TranslationServer.Translate("menu/load/legacy_title"),
OkButtonText = TranslationServer.Translate("menu/load/legacy_ok"),
CancelButtonText = TranslationServer.Translate("ui/save/cancel_button"),
Exclusive = true,
};
dialog.AddChild(content);
dialog.Confirmed += () =>
{
var gameId = games[Math.Max(0, select.Selected)].Collection.Id;
dialog.QueueFree();
var slot = SaveService.AdoptLegacySave(fileName, gameId);
RequestLoad(host, gameId, slot);
};
dialog.Canceled += dialog.QueueFree;
host.AddChild(dialog);
dialog.PopupCentered();
}
/// <summary>Message or confirmation dialog; without <paramref name="onConfirm"/> it is a plain notice.</summary>
public static void Confirm(Node host, string title, string text, string okText, Action onConfirm)
{
+8
View File
@@ -0,0 +1,8 @@
{
"name": "Resettables Test",
"description": "Integration test mod: switches games in one process (rpg_plus, novel_template, rpg_plus) and then starts a new game. The stats owner of a mod that leaves the set must be dropped with it, the reloaded mod must register exactly one new owner, its old context must be released, and New game must reset the state of the loaded mod. Activated only by --itest=resettables (with --frontend).",
"sources": [
"src/*.cs"
],
"depends": []
}
@@ -0,0 +1,237 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Loader;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Games;
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.ResettablesTest
{
/// <summary>
/// Plan 3.1: a mod that leaves the set takes its new-game owner
/// (<c>IResettable</c>) with it, and a mod loaded again registers exactly one
/// owner, from its new assembly. The stats mod (<c>stats_base</c>, id
/// <c>stats</c>) is the probe: it is in <c>rpg_plus</c> only.
/// <list type="number">
/// <item><c>rpg_plus</c> (new game): one <c>stats</c> owner, from the live
/// <c>stats_base</c> context; a character gains a stat.</item>
/// <item><c>novel_template</c>: <c>stats_base</c> unloaded, no <c>stats</c>
/// owner left, its old context released.</item>
/// <item><c>rpg_plus</c> again: exactly one <c>stats</c> owner, from the new
/// context; the character gains a stat again.</item>
/// <item>New game: the loaded state is reset (no characters left).</item>
/// </list>
/// The state is read through the mod's own types (reflection), because a suite
/// cannot depend on <c>stats_base</c> without keeping it loaded in every game.
/// Prints <c>[TEST_RESULT] name=resettables</c>.
/// </summary>
public partial class ResettablesTestDriverNode : Node
{
public const string Flag = "--itest=resettables";
private const string StatsMod = "stats_base";
private const string StatsOwnerId = "stats";
private const string StatStateType = "Cthangover.Mods.Stats.StatState";
private const string Probe = "itest_resettables";
private const string GameWithStats = "rpg_plus";
private const string GameWithoutStats = "novel_template";
private const float TimeoutSeconds = 420f;
private readonly List<string> failures = new();
private readonly List<string> unloadReport = new();
private int passed;
private double elapsed;
private bool started;
private bool done;
public override void _Ready()
{
// The driver keeps ticking across every mod-set switch of the run.
ProcessMode = ProcessModeEnum.Always;
GameLogger.LogEmitted += OnLogEmitted;
}
public override void _ExitTree()
{
GameLogger.LogEmitted -= OnLogEmitted;
}
private void OnLogEmitted(string category, string message, LogLevel level)
{
if (category != "MODS_UNLOAD")
return;
lock (unloadReport)
unloadReport.Add(message);
}
public override void _Process(double delta)
{
if (done)
return;
elapsed += delta;
if (elapsed > TimeoutSeconds)
{
failures.Add("timeout");
Finish();
return;
}
if (started || elapsed < 3 || !Boot.IsReady || GetTree().CurrentScene == null)
return;
if (GameSession.IsSwitching || ModManager.Instance.IsReloading)
return;
started = true;
RunAsync();
}
private async void RunAsync()
{
try
{
await Run();
}
catch (Exception ex)
{
failures.Add($"unhandled {ex.GetType().Name}: {ex.Message}");
}
Finish();
}
private async System.Threading.Tasks.Task Run()
{
// 1. Enter the game with the stats mod: one owner, from the live context.
var entered = await GameSession.SwitchToAsync(this, GameWithStats);
Check($"switched into '{GameWithStats}'", entered && ActiveGame.Id == GameWithStats);
Check($"'{GameWithStats}' released every old context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check($"'{StatsMod}' is loaded in '{GameWithStats}'", LiveStatsContexts().Count == 1);
Check("one stats owner is registered", StatsEntries().Count == 1);
Check("the stats owner comes from the loaded stats mod", OwnerIsLive());
Check("stats state is reachable", StateReachable());
MarkProbe();
Check("the stats state holds the probe before leaving", ProbeCount() == 1);
// 2. Leave for a game without the stats mod: its owner goes with it.
lock (unloadReport)
unloadReport.Clear();
entered = await GameSession.SwitchToAsync(this, GameWithoutStats);
Check($"switched into '{GameWithoutStats}'", entered && ActiveGame.Id == GameWithoutStats);
Check($"'{StatsMod}' left the set", ModManager.Instance.LastReload?.Plan?.Unload.Contains(StatsMod) == true);
Check($"'{GameWithoutStats}' released the old stats context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check("no stats owner remains after it left", StatsEntries().Count == 0);
Check("no stats context stays alive", LiveStatsContexts().Count == 0);
// The unload pass drops the owners itself; the static sweep dropping them
// behind the owner's back is only a fallback that the report names.
lock (unloadReport)
{
Check("the unload pass dropped the mod's new-game owners itself", unloadReport.Any(m => m.Contains("new-game resettable(s) of mod code")));
Check("the static sweep did not have to drop a new-game owner", !unloadReport.Any(m => m.Contains("AppServices.resettables: removed")));
}
// 3. Back with the stats mod: a single owner, from the new context.
entered = await GameSession.SwitchToAsync(this, GameWithStats);
Check($"switched back into '{GameWithStats}'", entered && ActiveGame.Id == GameWithStats);
Check($"'{GameWithStats}' (again) released every old context", ModManager.Instance.LastReload?.Leaked.Count == 0);
Check("exactly one stats owner after the reload", StatsEntries().Count == 1);
Check("the stats owner comes from the reloaded stats mod", OwnerIsLive());
Check("stats state is reachable after the reload", StateReachable());
MarkProbe();
Check("the reloaded state holds the probe before New game", ProbeCount() == 1);
// 4. New game resets the state of the loaded mod.
GameData.Instance.StartNewGame();
Check("New game reset the stats of the loaded mod", ProbeCount() == 0);
Check($"still in '{GameWithStats}' after New game", ActiveGame.Id == GameWithStats);
}
/// <summary>Live (not yet released) contexts of the stats mod.</summary>
private static List<ModLoadContext> LiveStatsContexts()
=> AssemblyLoadContext.All.OfType<ModLoadContext>()
.Where(c => c.ModId == StatsMod && !c.IsReleased)
.ToList();
private static List<IResettable> StatsEntries()
=> AppServices.Current.Resettables.Where(r => r.Id == StatsOwnerId).ToList();
/// <summary>True when the single stats owner's assembly is one of the live stats mod's assemblies.</summary>
private static bool OwnerIsLive()
{
var entries = StatsEntries();
if (entries.Count != 1)
return false;
var owner = entries[0].GetType().Assembly;
return LiveStatsContexts().Any(c => c.Assemblies.Contains(owner));
}
// The state objects below are only ever used inside these helpers: a local
// holding a StatState would keep the stats context alive across the next
// switch (debug builds extend locals to the end of the method).
/// <summary>Reads <c>StatState.Instance</c> of the live stats mod; null when it is not reachable.</summary>
private static object CurrentStatState()
{
foreach (var context in LiveStatsContexts())
{
foreach (var assembly in context.Assemblies)
{
var type = assembly.GetType(StatStateType);
if (type != null)
return type.GetProperty("Instance")?.GetValue(null);
}
}
return null;
}
private static bool StateReachable() => CurrentStatState() != null;
/// <summary>Gives the loaded stats state one character with a gained stat.</summary>
private static void MarkProbe()
{
var state = CurrentStatState();
state?.GetType().GetMethod("Of", new[] { typeof(string) })?.Invoke(state, new object[] { Probe });
}
/// <summary>Number of characters with stat gains in the loaded stats state; -1 when unreachable.</summary>
private static int ProbeCount()
{
var state = CurrentStatState();
var data = state?.GetType().GetProperty("Data")?.GetValue(state);
var characters = data?.GetType().GetProperty("Characters")?.GetValue(data) as IDictionary;
return characters?.Count ?? -1;
}
private void Check(string name, bool ok)
{
if (ok)
{
passed++;
GameLogger.Log("TEST", $"ResettablesTest: [OK] {name}");
}
else
{
failures.Add(name);
GameLogger.Log("TEST", $"ResettablesTest: [FAIL] {name}", LogLevel.Error);
}
}
private void Finish()
{
if (done)
return;
done = true;
foreach (var failure in failures)
GameLogger.Log("TEST", $"ResettablesTest: failed: {failure}");
GameLogger.Log("TEST", $"[TEST_RESULT] name=resettables passed={passed} failed={failures.Count}");
ApplicationExit.Quit(GetTree(), failures.Count == 0 ? 0 : 1);
}
}
}
@@ -0,0 +1,25 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Mods.ResettablesTest
{
/// <summary>Queues <see cref="ResettablesTestDriverNode"/> only for <c>--itest=resettables</c>.</summary>
public class ResettablesTestInitializer : IModInitializer
{
private static bool queued;
public void OnModLoaded(string modId) { }
public void OnModResourcesReady()
{
if (queued || !OS.GetCmdlineUserArgs().Contains(ResettablesTestDriverNode.Flag))
return;
queued = true;
GameLogger.Log("TEST", "ResettablesTest: activating");
ModRoot.AddDeferred(new ResettablesTestDriverNode());
}
}
}