1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-21 10:50:20 +08:00

Compare commits

...

115 Commits

87 changed files with 1729 additions and 955 deletions
+1 -1
View File
@@ -52,7 +52,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1203.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2021.1207.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->
@@ -174,7 +174,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private Drawable setupSkinHierarchy(Drawable child, ISkin skin)
{
var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info));
var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.CreateInfo()));
var testSkinProvider = new SkinProvidingContainer(skin);
var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider));
@@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Screens.Edit;
@@ -55,13 +56,13 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test]
public void TestDefaultSkin()
{
AddStep("set default skin", () => skins.CurrentSkinInfo.Value = SkinInfo.Default);
AddStep("set default skin", () => skins.CurrentSkinInfo.Value = DefaultSkin.CreateInfo().ToLive());
}
[Test]
public void TestLegacySkin()
{
AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.Info);
AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.CreateInfo().ToLive());
}
}
}
@@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("create slider", () =>
{
var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.Info);
var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo());
tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1";
Child = new SkinProvidingContainer(tintingSkin)
@@ -2,14 +2,20 @@
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko;
using osu.Game.Scoring;
@@ -21,6 +27,14 @@ namespace osu.Game.Tests.Beatmaps.Formats
[TestFixture]
public class LegacyScoreDecoderTest
{
private CultureInfo originalCulture;
[SetUp]
public void SetUp()
{
originalCulture = CultureInfo.CurrentCulture;
}
[Test]
public void TestDecodeManiaReplay()
{
@@ -44,6 +58,59 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
}
[Test]
public void TestCultureInvariance()
{
var ruleset = new OsuRuleset().RulesetInfo;
var scoreInfo = new TestScoreInfo(ruleset);
var beatmap = new TestBeatmap(ruleset);
var score = new Score
{
ScoreInfo = scoreInfo,
Replay = new Replay
{
Frames = new List<ReplayFrame>
{
new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton)
}
}
};
// the "se" culture is used here, as it encodes the negative number sign as U+2212 MINUS SIGN,
// rather than the classic ASCII U+002D HYPHEN-MINUS.
CultureInfo.CurrentCulture = new CultureInfo("se");
var encodeStream = new MemoryStream();
var encoder = new LegacyScoreEncoder(score, beatmap);
encoder.Encode(encodeStream);
var decodeStream = new MemoryStream(encodeStream.GetBuffer());
var decoder = new TestLegacyScoreDecoder();
var decodedAfterEncode = decoder.Parse(decodeStream);
Assert.Multiple(() =>
{
Assert.That(decodedAfterEncode, Is.Not.Null);
Assert.That(decodedAfterEncode.ScoreInfo.User.Username, Is.EqualTo(scoreInfo.User.Username));
Assert.That(decodedAfterEncode.ScoreInfo.BeatmapInfoID, Is.EqualTo(scoreInfo.BeatmapInfoID));
Assert.That(decodedAfterEncode.ScoreInfo.Ruleset, Is.EqualTo(scoreInfo.Ruleset));
Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(scoreInfo.TotalScore));
Assert.That(decodedAfterEncode.ScoreInfo.MaxCombo, Is.EqualTo(scoreInfo.MaxCombo));
Assert.That(decodedAfterEncode.ScoreInfo.Date, Is.EqualTo(scoreInfo.Date));
Assert.That(decodedAfterEncode.Replay.Frames.Count, Is.EqualTo(1));
});
}
[TearDown]
public void TearDown()
{
CultureInfo.CurrentCulture = originalCulture;
}
private class TestLegacyScoreDecoder : LegacyScoreDecoder
{
private static readonly Dictionary<int, Ruleset> rulesets = new Ruleset[]
+3 -3
View File
@@ -45,9 +45,9 @@ namespace osu.Game.Tests.Database
{
var rulesets = new RealmRulesetStore(realmFactory, storage);
Assert.IsTrue((rulesets.AvailableRulesets.First() as RealmRuleset)?.IsManaged == false);
Assert.IsTrue((rulesets.GetRuleset(0) as RealmRuleset)?.IsManaged == false);
Assert.IsTrue((rulesets.GetRuleset("mania") as RealmRuleset)?.IsManaged == false);
Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged);
Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged);
Assert.IsFalse(rulesets.GetRuleset("mania")?.IsManaged);
});
}
}
@@ -167,7 +167,7 @@ namespace osu.Game.Tests.Gameplay
private class TestSkin : LegacySkin
{
public TestSkin(string resourceName, IStorageResourceProvider resources)
: base(DefaultLegacySkin.Info, new TestResourceStore(resourceName), resources, "skin.ini")
: base(DefaultLegacySkin.CreateInfo(), new TestResourceStore(resourceName), resources, "skin.ini")
{
}
}
+95 -17
View File
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Skinning;
@@ -163,32 +164,109 @@ namespace osu.Game.Tests.Skins.IO
assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", osu);
});
[Test]
public Task TestExportThenImportDefaultSkin() => runSkinTest(osu =>
{
var skinManager = osu.Dependencies.Get<SkinManager>();
skinManager.EnsureMutableSkin();
MemoryStream exportStream = new MemoryStream();
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
skinManager.CurrentSkinInfo.Value.PerformRead(s =>
{
Assert.IsFalse(s.Protected);
Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType());
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
Assert.Greater(exportStream.Length, 0);
});
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk"));
imported.Result.PerformRead(s =>
{
Assert.IsFalse(s.Protected);
Assert.AreNotEqual(originalSkinId, s.ID);
Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType());
});
return Task.CompletedTask;
});
[Test]
public Task TestExportThenImportClassicSkin() => runSkinTest(osu =>
{
var skinManager = osu.Dependencies.Get<SkinManager>();
skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo;
skinManager.EnsureMutableSkin();
MemoryStream exportStream = new MemoryStream();
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
skinManager.CurrentSkinInfo.Value.PerformRead(s =>
{
Assert.IsFalse(s.Protected);
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
Assert.Greater(exportStream.Length, 0);
});
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk"));
imported.Result.PerformRead(s =>
{
Assert.IsFalse(s.Protected);
Assert.AreNotEqual(originalSkinId, s.ID);
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
});
return Task.CompletedTask;
});
#endregion
private void assertCorrectMetadata(SkinInfo import1, string name, string creator, OsuGameBase osu)
private void assertCorrectMetadata(ILive<SkinInfo> import1, string name, string creator, OsuGameBase osu)
{
Assert.That(import1.Name, Is.EqualTo(name));
Assert.That(import1.Creator, Is.EqualTo(creator));
import1.PerformRead(i =>
{
Assert.That(i.Name, Is.EqualTo(name));
Assert.That(i.Creator, Is.EqualTo(creator));
// for extra safety let's reconstruct the skin, reading from the skin.ini.
var instance = import1.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager)));
// for extra safety let's reconstruct the skin, reading from the skin.ini.
var instance = i.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager)));
Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name));
Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator));
Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name));
Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator));
});
}
private void assertImportedBoth(SkinInfo import1, SkinInfo import2)
private void assertImportedBoth(ILive<SkinInfo> import1, ILive<SkinInfo> import2)
{
Assert.That(import2.ID, Is.Not.EqualTo(import1.ID));
Assert.That(import2.Hash, Is.Not.EqualTo(import1.Hash));
Assert.That(import2.Files.Select(f => f.FileInfoID), Is.Not.EquivalentTo(import1.Files.Select(f => f.FileInfoID)));
import1.PerformRead(i1 => import2.PerformRead(i2 =>
{
Assert.That(i2.ID, Is.Not.EqualTo(i1.ID));
Assert.That(i2.Hash, Is.Not.EqualTo(i1.Hash));
Assert.That(i2.Files.First(), Is.Not.EqualTo(i1.Files.First()));
}));
}
private void assertImportedOnce(SkinInfo import1, SkinInfo import2)
private void assertImportedOnce(ILive<SkinInfo> import1, ILive<SkinInfo> import2)
{
Assert.That(import2.ID, Is.EqualTo(import1.ID));
Assert.That(import2.Hash, Is.EqualTo(import1.Hash));
Assert.That(import2.Files.Select(f => f.FileInfoID), Is.EquivalentTo(import1.Files.Select(f => f.FileInfoID)));
import1.PerformRead(i1 => import2.PerformRead(i2 =>
{
Assert.That(i2.ID, Is.EqualTo(i1.ID));
Assert.That(i2.Hash, Is.EqualTo(i1.Hash));
Assert.That(i2.Files.First(), Is.EqualTo(i1.Files.First()));
}));
}
private MemoryStream createEmptyOsk()
@@ -255,10 +333,10 @@ namespace osu.Game.Tests.Skins.IO
}
}
private async Task<SkinInfo> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null)
private async Task<ILive<SkinInfo>> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null)
{
var skinManager = osu.Dependencies.Get<SkinManager>();
return (await skinManager.Import(archive)).Value;
return await skinManager.Import(archive);
}
}
}
@@ -24,7 +24,7 @@ namespace osu.Game.Tests.Skins
private void load()
{
var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result;
skin = skins.GetSkin(imported.Value);
skin = imported.PerformRead(skinInfo => skins.GetSkin(skinInfo));
}
[Test]
@@ -8,6 +8,7 @@ using osu.Framework.Allocation;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
@@ -122,7 +123,7 @@ namespace osu.Game.Tests.Visual.Background
private void setCustomSkin()
{
// feign a skin switch. this doesn't do anything except force CurrentSkin to become a LegacySkin.
AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo { ID = 5 });
AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo().ToLive());
}
private void setDefaultSkin() => AddStep("set default skin", () => skins.CurrentSkinInfo.SetDefault());
@@ -12,6 +12,7 @@ using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
@@ -41,7 +42,7 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestEmptyLegacyBeatmapSkinFallsBack()
{
CreateSkinTest(SkinInfo.Default, () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null));
CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null));
AddUntilStep("wait for hud load", () => Player.ChildrenOfType<SkinnableTargetContainer>().All(c => c.ComponentsLoaded));
AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value));
}
@@ -52,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("setup skins", () =>
{
skinManager.CurrentSkinInfo.Value = gameCurrentSkin;
skinManager.CurrentSkinInfo.Value = gameCurrentSkin.ToLive();
currentBeatmapSkin = getBeatmapSkin();
});
});
@@ -43,83 +43,88 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached]
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
[SetUp]
public void SetUp() => Schedule(() =>
[SetUpSteps]
public void SetUpSteps()
{
replay = new Replay();
AddStep("Reset recorder state", cleanUpState);
Add(new GridContainer
AddStep("Setup containers", () =>
{
RelativeSizeAxes = Axes.Both,
Content = new[]
replay = new Replay();
Add(new GridContainer
{
new Drawable[]
RelativeSizeAxes = Axes.Both,
Content = new[]
{
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
new Drawable[]
{
Recorder = recorder = new TestReplayRecorder(new Score
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
{
Replay = replay,
ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo }
})
{
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
Recorder = recorder = new TestReplayRecorder(new Score
{
new Box
Replay = replay,
ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo }
})
{
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
Colour = Color4.Brown,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Recording",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
}
},
new Drawable[]
{
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
new Box
{
Colour = Color4.Brown,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Recording",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
}
},
new Drawable[]
{
ReplayInputHandler = new TestFramedReplayInputHandler(replay)
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
{
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
ReplayInputHandler = new TestFramedReplayInputHandler(replay)
{
new Box
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
Colour = Color4.DarkBlue,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Playback",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
new Box
{
Colour = Color4.DarkBlue,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Playback",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
}
}
}
}
});
});
});
}
[Test]
public void TestBasic()
@@ -184,7 +189,14 @@ namespace osu.Game.Tests.Visual.Gameplay
[TearDownSteps]
public void TearDown()
{
AddStep("stop recorder", () => recorder.Expire());
AddStep("stop recorder", cleanUpState);
}
private void cleanUpState()
{
// Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`.
recorder?.RemoveAndDisposeImmediately();
recorder = null;
}
public class TestFramedReplayInputHandler : FramedReplayInputHandler<TestReplayFrame>
@@ -1,228 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Input.StateChanges;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Sprites;
using osu.Game.Replays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual.UserInterface;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.Gameplay
{
public class TestSceneReplayRecording : OsuTestScene
{
private readonly TestRulesetInputManager playbackManager;
private readonly TestRulesetInputManager recordingManager;
[Cached]
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
public TestSceneReplayRecording()
{
Replay replay = new Replay();
Add(new GridContainer
{
RelativeSizeAxes = Axes.Both,
Content = new[]
{
new Drawable[]
{
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
{
Recorder = new TestReplayRecorder(new Score
{
Replay = replay,
ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo }
})
{
ScreenSpaceToGamefield = pos => recordingManager?.ToLocalSpace(pos) ?? Vector2.Zero,
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = Color4.Brown,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Recording",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestConsumer()
}
},
}
},
new Drawable[]
{
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
{
ReplayInputHandler = new TestFramedReplayInputHandler(replay)
{
GamefieldToScreenSpace = pos => playbackManager?.ToScreenSpace(pos) ?? Vector2.Zero,
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = Color4.DarkBlue,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Playback",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestConsumer()
}
},
}
}
}
});
}
protected override void Update()
{
base.Update();
playbackManager.ReplayInputHandler.SetFrameFromTime(Time.Current - 500);
}
}
public class TestFramedReplayInputHandler : FramedReplayInputHandler<TestReplayFrame>
{
public TestFramedReplayInputHandler(Replay replay)
: base(replay)
{
}
public override void CollectPendingInputs(List<IInput> inputs)
{
inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) });
inputs.Add(new ReplayState<TestAction> { PressedActions = CurrentFrame?.Actions ?? new List<TestAction>() });
}
}
public class TestConsumer : CompositeDrawable, IKeyBindingHandler<TestAction>
{
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos);
private readonly Box box;
public TestConsumer()
{
Size = new Vector2(30);
Origin = Anchor.Centre;
InternalChildren = new Drawable[]
{
box = new Box
{
Colour = Color4.Black,
RelativeSizeAxes = Axes.Both,
},
};
}
protected override bool OnMouseMove(MouseMoveEvent e)
{
Position = e.MousePosition;
return base.OnMouseMove(e);
}
public bool OnPressed(KeyBindingPressEvent<TestAction> e)
{
if (e.Repeat)
return false;
box.Colour = Color4.White;
return true;
}
public void OnReleased(KeyBindingReleaseEvent<TestAction> e)
{
box.Colour = Color4.Black;
}
}
public class TestRulesetInputManager : RulesetInputManager<TestAction>
{
public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique)
: base(ruleset, variant, unique)
{
}
protected override KeyBindingContainer<TestAction> CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique)
=> new TestKeyBindingContainer();
internal class TestKeyBindingContainer : KeyBindingContainer<TestAction>
{
public override IEnumerable<IKeyBinding> DefaultKeyBindings => new[]
{
new KeyBinding(InputKey.MouseLeft, TestAction.Down),
};
}
}
public class TestReplayFrame : ReplayFrame
{
public Vector2 Position;
public List<TestAction> Actions = new List<TestAction>();
public TestReplayFrame(double time, Vector2 position, params TestAction[] actions)
: base(time)
{
Position = position;
Actions.AddRange(actions);
}
}
public enum TestAction
{
Down,
}
internal class TestReplayRecorder : ReplayRecorder<TestAction>
{
public TestReplayRecorder(Score target)
: base(target)
{
}
protected override ReplayFrame HandleFrame(Vector2 mousePosition, List<TestAction> actions, ReplayFrame previousFrame) =>
new TestReplayFrame(Time.Current, mousePosition, actions.ToArray());
}
}
@@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private TestReplayRecorder recorder;
private readonly ManualClock manualClock = new ManualClock();
private ManualClock manualClock;
private OsuSpriteText latencyDisplay;
@@ -66,113 +66,121 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached]
private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty<Mod>());
[SetUp]
public void SetUp() => Schedule(() =>
[SetUpSteps]
public void SetUpSteps()
{
replay = new Replay();
AddStep("Reset recorder state", cleanUpState);
users.BindTo(spectatorClient.PlayingUsers);
users.BindCollectionChanged((obj, args) =>
AddStep("Setup containers", () =>
{
switch (args.Action)
replay = new Replay();
manualClock = new ManualClock();
spectatorClient.OnNewFrames += onNewFrames;
users.BindTo(spectatorClient.PlayingUsers);
users.BindCollectionChanged((obj, args) =>
{
case NotifyCollectionChangedAction.Add:
Debug.Assert(args.NewItems != null);
foreach (int user in args.NewItems)
{
if (user == api.LocalUser.Value.Id)
spectatorClient.WatchUser(user);
}
break;
case NotifyCollectionChangedAction.Remove:
Debug.Assert(args.OldItems != null);
foreach (int user in args.OldItems)
{
if (user == api.LocalUser.Value.Id)
spectatorClient.StopWatchingUser(user);
}
break;
}
}, true);
spectatorClient.OnNewFrames += onNewFrames;
Add(new GridContainer
{
RelativeSizeAxes = Axes.Both,
Content = new[]
{
new Drawable[]
switch (args.Action)
{
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
case NotifyCollectionChangedAction.Add:
Debug.Assert(args.NewItems != null);
foreach (int user in args.NewItems)
{
if (user == api.LocalUser.Value.Id)
spectatorClient.WatchUser(user);
}
break;
case NotifyCollectionChangedAction.Remove:
Debug.Assert(args.OldItems != null);
foreach (int user in args.OldItems)
{
if (user == api.LocalUser.Value.Id)
spectatorClient.StopWatchingUser(user);
}
break;
}
}, true);
Children = new Drawable[]
{
new GridContainer
{
RelativeSizeAxes = Axes.Both,
Content = new[]
{
Recorder = recorder = new TestReplayRecorder
new Drawable[]
{
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
{
new Box
Recorder = recorder = new TestReplayRecorder
{
ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos),
},
Child = new Container
{
Colour = Color4.Brown,
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = Color4.Brown,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Sending",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
new OsuSpriteText
{
Text = "Sending",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
new Drawable[]
{
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
{
Clock = new FramedClock(manualClock),
ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay)
{
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = Color4.DarkBlue,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Receiving",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
}
}
}
},
new Drawable[]
{
playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique)
{
Clock = new FramedClock(manualClock),
ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay)
{
GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos),
},
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Box
{
Colour = Color4.DarkBlue,
RelativeSizeAxes = Axes.Both,
},
new OsuSpriteText
{
Text = "Receiving",
Scale = new Vector2(3),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
new TestInputConsumer()
}
},
}
}
}
latencyDisplay = new OsuSpriteText()
};
});
Add(latencyDisplay = new OsuSpriteText());
});
}
private void onNewFrames(int userId, FrameDataBundle frames)
{
@@ -189,6 +197,7 @@ namespace osu.Game.Tests.Visual.Gameplay
[Test]
public void TestBasic()
{
AddStep("Wait for user input", () => { });
}
private double latency = SpectatorClient.TIME_BETWEEN_SENDS;
@@ -232,11 +241,15 @@ namespace osu.Game.Tests.Visual.Gameplay
[TearDownSteps]
public void TearDown()
{
AddStep("stop recorder", () =>
{
recorder.Expire();
spectatorClient.OnNewFrames -= onNewFrames;
});
AddStep("stop recorder", cleanUpState);
}
private void cleanUpState()
{
// Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`.
recorder?.RemoveAndDisposeImmediately();
recorder = null;
spectatorClient.OnNewFrames -= onNewFrames;
}
public class TestFramedReplayInputHandler : FramedReplayInputHandler<TestReplayFrame>
@@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Menus
private TestToolbar toolbar;
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
[SetUp]
public void SetUp() => Schedule(() =>
@@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Lounge;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.OnlinePlay.Match;
using osu.Game.Tests.Beatmaps;
using osuTK;
@@ -172,6 +174,39 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType<DrawableRoom.PasswordProtectedIcon>().Single().Alpha));
}
[Test]
public void TestMultiplayerRooms()
{
AddStep("create rooms", () => Child = new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Direction = FillDirection.Vertical,
Spacing = new Vector2(5),
Children = new[]
{
new DrawableMatchRoom(new Room
{
Name = { Value = "A host-only room" },
QueueMode = { Value = QueueMode.HostOnly },
Type = { Value = MatchType.HeadToHead }
}),
new DrawableMatchRoom(new Room
{
Name = { Value = "An all-players, team-versus room" },
QueueMode = { Value = QueueMode.AllPlayers },
Type = { Value = MatchType.TeamVersus }
}),
new DrawableMatchRoom(new Room
{
Name = { Value = "A round-robin room" },
QueueMode = { Value = QueueMode.AllPlayersRoundRobin },
Type = { Value = MatchType.HeadToHead }
}),
}
});
}
private DrawableRoom createLoungeRoom(Room room)
{
room.Host.Value ??= new APIUser { Username = "peppy", Id = 2 };
@@ -397,6 +397,44 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("user state is idle", () => client.LocalUser?.State == MultiplayerUserState.Idle);
}
[Test]
public void TestPlayStartsWithCorrectBeatmapWhileAtSongSelect()
{
createRoom(() => new Room
{
Name = { Value = "Test Room" },
Playlist =
{
new PlaylistItem
{
Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
}
}
});
AddStep("Enter song select", () =>
{
var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerScreenStack.CurrentScreen).CurrentSubScreen;
((MultiplayerMatchSubScreen)currentSubScreen).SelectBeatmap();
});
AddUntilStep("wait for song select", () => this.ChildrenOfType<MultiplayerMatchSongSelect>().FirstOrDefault()?.BeatmapSetsLoaded == true);
AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == client.Room?.Playlist.First().BeatmapID);
AddStep("Select next beatmap", () => InputManager.Key(Key.Down));
AddUntilStep("Beatmap doesn't match current item", () => Beatmap.Value.BeatmapInfo.OnlineID != client.Room?.Playlist.First().BeatmapID);
AddStep("start match externally", () => client.StartMatch());
AddUntilStep("play started", () => multiplayerScreenStack.CurrentScreen is Player);
AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == client.Room?.Playlist.First().BeatmapID);
}
[Test]
public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap()
{
@@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Testing;
using osu.Game.Overlays.Settings.Sections;
using osu.Game.Skinning;
using osu.Game.Skinning.Editor;
namespace osu.Game.Tests.Visual.Navigation
{
public class TestSceneEditDefaultSkin : OsuGameTestScene
{
private SkinManager skinManager => Game.Dependencies.Get<SkinManager>();
private SkinEditorOverlay skinEditor => Game.Dependencies.Get<SkinEditorOverlay>();
[Test]
public void TestEditDefaultSkin()
{
AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.DEFAULT_SKIN);
AddStep("open settings", () => { Game.Settings.Show(); });
// Until step requires as settings has a delayed load.
AddUntilStep("export button disabled", () => Game.Settings.ChildrenOfType<SkinSection.ExportSkinButton>().SingleOrDefault()?.Enabled.Value == false);
// Will create a mutable skin.
AddStep("open skin editor", () => skinEditor.Show());
// Until step required as the skin editor may take time to load (and an extra scheduled frame for the mutable part).
AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.DEFAULT_SKIN);
AddAssert("is not protected", () => skinManager.CurrentSkinInfo.Value.PerformRead(s => !s.Protected));
AddUntilStep("export button enabled", () => Game.Settings.ChildrenOfType<SkinSection.ExportSkinButton>().SingleOrDefault()?.Enabled.Value == true);
}
}
}
@@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Online
}
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
[Test]
public void TestMultipleRulesetsBeatmapSet()
@@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.Online
}
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
[Test]
public void TestLoading()
@@ -24,7 +24,7 @@ namespace osu.Game.Tests.Visual.Online
private TestUserListPanel evast;
[Resolved]
private RulesetStore rulesetStore { get; set; }
private IRulesetStore rulesetStore { get; set; }
[SetUp]
public void SetUp() => Schedule(() =>
@@ -135,6 +135,35 @@ namespace osu.Game.Tests.Visual.UserInterface
AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60));
}
[TestCase(true)]
[TestCase(false)]
public void TestEarlyActivationEffectPoint(bool earlyActivating)
{
double earlyActivationMilliseconds = earlyActivating ? 100 : 0;
ControlPoint actualEffectPoint = null;
AddStep($"set early activation to {earlyActivationMilliseconds}", () => beatContainer.EarlyActivationMilliseconds = earlyActivationMilliseconds);
AddStep("seek before kiai effect point", () =>
{
ControlPoint expectedEffectPoint = Beatmap.Value.Beatmap.ControlPointInfo.EffectPoints.First(ep => ep.KiaiMode);
actualEffectPoint = null;
beatContainer.AllowMistimedEventFiring = false;
beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) =>
{
if (Precision.AlmostEquals(gameplayClockContainer.CurrentTime + earlyActivationMilliseconds, expectedEffectPoint.Time, BeatSyncedContainer.MISTIMED_ALLOWANCE))
actualEffectPoint = effectControlPoint;
};
gameplayClockContainer.Seek(expectedEffectPoint.Time - earlyActivationMilliseconds);
});
AddUntilStep("wait for effect point", () => actualEffectPoint != null);
AddAssert("effect has kiai", () => actualEffectPoint != null && ((EffectControlPoint)actualEffectPoint).KiaiMode);
}
private class TestBeatSyncedContainer : BeatSyncedContainer
{
private const int flash_layer_height = 150;
@@ -145,6 +174,12 @@ namespace osu.Game.Tests.Visual.UserInterface
set => base.AllowMistimedEventFiring = value;
}
public new double EarlyActivationMilliseconds
{
get => base.EarlyActivationMilliseconds;
set => base.EarlyActivationMilliseconds = value;
}
private readonly InfoString timingPointCount;
private readonly InfoString currentTimingPoint;
private readonly InfoString beatCount;
@@ -6,12 +6,17 @@ using osu.Framework.Graphics;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Tests.Visual;
using osu.Game.Tournament.Components;
namespace osu.Game.Tournament.Tests.Components
{
public class TestSceneTournamentBeatmapPanel : TournamentTestScene
{
/// <remarks>
/// Warning: the below API instance is actually the online API, rather than the dummy API provided by the test.
/// It cannot be trivially replaced because setting <see cref="OsuTestScene.UseOnlineAPI"/> to <see langword="true"/> causes <see cref="OsuTestScene.API"/> to no longer be usable.
/// </remarks>
[Resolved]
private IAPIProvider api { get; set; }
@@ -19,7 +19,7 @@ namespace osu.Game.Tournament.Tests.Components
private IAPIProvider api { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
private FillFlowContainer<TournamentBeatmapPanel> fillFlow;
@@ -21,7 +21,7 @@ namespace osu.Game.Tournament.Components
private readonly string modAcronym;
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
public TournamentModIcon(string modAcronym)
{
+1 -1
View File
@@ -28,7 +28,7 @@ namespace osu.Game.Tournament.IPC
protected IAPIProvider API { get; private set; }
[Resolved]
protected RulesetStore Rulesets { get; private set; }
protected IRulesetStore Rulesets { get; private set; }
[Resolved]
private GameHost host { get; set; }
+3 -3
View File
@@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps
private IAPIProvider api { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
[Resolved]
private Bindable<RulesetInfo> ruleset { get; set; }
@@ -35,7 +35,7 @@ namespace osu.Game.Beatmaps
/// </summary>
private int? requestedUserId;
private readonly Dictionary<RulesetInfo, double> recommendedDifficultyMapping = new Dictionary<RulesetInfo, double>();
private readonly Dictionary<IRulesetInfo, double> recommendedDifficultyMapping = new Dictionary<IRulesetInfo, double>();
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
@@ -101,7 +101,7 @@ namespace osu.Game.Beatmaps
/// Rulesets ordered descending by their respective recommended difficulties.
/// The currently selected ruleset will always be first.
/// </returns>
private IEnumerable<RulesetInfo> orderedRulesets
private IEnumerable<IRulesetInfo> orderedRulesets
{
get
{
@@ -86,7 +86,7 @@ namespace osu.Game.Beatmaps.Drawables
}
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
+8 -4
View File
@@ -17,6 +17,7 @@ using osu.Game.Overlays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Skinning;
namespace osu.Game.Configuration
{
@@ -27,7 +28,7 @@ namespace osu.Game.Configuration
{
// UI/selection defaults
SetDefault(OsuSetting.Ruleset, string.Empty);
SetDefault(OsuSetting.Skin, 0, -1, int.MaxValue);
SetDefault(OsuSetting.Skin, SkinInfo.DEFAULT_SKIN.ToString());
SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details);
SetDefault(OsuSetting.BeatmapDetailModsFilter, false);
@@ -210,9 +211,12 @@ namespace osu.Game.Configuration
value: scalingMode.GetLocalisableDescription()
)
),
new TrackedSetting<int>(OsuSetting.Skin, skin =>
new TrackedSetting<string>(OsuSetting.Skin, skin =>
{
string skinName = LookupSkinName(skin) ?? string.Empty;
string skinName = string.Empty;
if (Guid.TryParse(skin, out var id))
skinName = LookupSkinName(id) ?? string.Empty;
return new SettingDescription(
rawValue: skinName,
@@ -233,7 +237,7 @@ namespace osu.Game.Configuration
};
}
public Func<int, string> LookupSkinName { private get; set; }
public Func<Guid, string> LookupSkinName { private get; set; }
public Func<GlobalAction, LocalisableString> LookupKeyBindings { get; set; }
}
+148
View File
@@ -0,0 +1,148 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using Microsoft.EntityFrameworkCore;
using osu.Game.Configuration;
using osu.Game.Models;
using osu.Game.Skinning;
#nullable enable
namespace osu.Game.Database
{
internal class EFToRealmMigrator
{
private readonly DatabaseContextFactory efContextFactory;
private readonly RealmContextFactory realmContextFactory;
private readonly OsuConfigManager config;
public EFToRealmMigrator(DatabaseContextFactory efContextFactory, RealmContextFactory realmContextFactory, OsuConfigManager config)
{
this.efContextFactory = efContextFactory;
this.realmContextFactory = realmContextFactory;
this.config = config;
}
public void Run()
{
using (var db = efContextFactory.GetForWrite())
{
migrateSettings(db);
migrateSkins(db);
}
}
private void migrateSkins(DatabaseWriteUsage db)
{
// can be removed 20220530.
var existingSkins = db.Context.SkinInfo
.Include(s => s.Files)
.ThenInclude(f => f.FileInfo)
.ToList();
// previous entries in EF are removed post migration.
if (!existingSkins.Any())
return;
var userSkinChoice = config.GetBindable<string>(OsuSetting.Skin);
int.TryParse(userSkinChoice.Value, out int userSkinInt);
switch (userSkinInt)
{
case EFSkinInfo.DEFAULT_SKIN:
userSkinChoice.Value = SkinInfo.DEFAULT_SKIN.ToString();
break;
case EFSkinInfo.CLASSIC_SKIN:
userSkinChoice.Value = SkinInfo.CLASSIC_SKIN.ToString();
break;
}
using (var realm = realmContextFactory.CreateContext())
using (var transaction = realm.BeginWrite())
{
// only migrate data if the realm database is empty.
// note that this cannot be written as: `realm.All<SkinInfo>().All(s => s.Protected)`, because realm does not support `.All()`.
if (!realm.All<SkinInfo>().Any(s => !s.Protected))
{
foreach (var skin in existingSkins)
{
var realmSkin = new SkinInfo
{
Name = skin.Name,
Creator = skin.Creator,
Hash = skin.Hash,
Protected = false,
InstantiationInfo = skin.InstantiationInfo,
};
foreach (var file in skin.Files)
{
var realmFile = realm.Find<RealmFile>(file.FileInfo.Hash);
if (realmFile == null)
realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash });
realmSkin.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename));
}
realm.Add(realmSkin);
if (skin.ID == userSkinInt)
userSkinChoice.Value = realmSkin.ID.ToString();
}
}
db.Context.RemoveRange(existingSkins);
// Intentionally don't clean up the files, so they don't get purged by EF.
transaction.Commit();
}
}
private void migrateSettings(DatabaseWriteUsage db)
{
// migrate ruleset settings. can be removed 20220315.
var existingSettings = db.Context.DatabasedSetting;
// previous entries in EF are removed post migration.
if (!existingSettings.Any())
return;
using (var realm = realmContextFactory.CreateContext())
using (var transaction = realm.BeginWrite())
{
// only migrate data if the realm database is empty.
if (!realm.All<RealmRulesetSetting>().Any())
{
foreach (var dkb in existingSettings)
{
if (dkb.RulesetID == null)
continue;
string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value);
if (string.IsNullOrEmpty(shortName))
continue;
realm.Add(new RealmRulesetSetting
{
Key = dkb.Key,
Value = dkb.StringValue,
RulesetName = shortName,
Variant = dkb.Variant ?? 0,
});
}
}
db.Context.RemoveRange(existingSettings);
transaction.Commit();
}
}
private string? getRulesetShortNameFromLegacyID(long rulesetId) =>
efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName;
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ namespace osu.Game.Database
void DeleteFile(TModel model, TFileModel file);
/// <summary>
/// Add a new file.
/// Add a new file. If the file already exists, it is overwritten.
/// </summary>
/// <param name="model">The item to operate on.</param>
/// <param name="contents">The new file contents.</param>
+4 -3
View File
@@ -25,7 +25,7 @@ namespace osu.Game.Database
public DbSet<BeatmapSetInfo> BeatmapSetInfo { get; set; }
public DbSet<FileInfo> FileInfo { get; set; }
public DbSet<RulesetInfo> RulesetInfo { get; set; }
public DbSet<SkinInfo> SkinInfo { get; set; }
public DbSet<EFSkinInfo> SkinInfo { get; set; }
public DbSet<ScoreInfo> ScoreInfo { get; set; }
// migrated to realm
@@ -133,8 +133,9 @@ namespace osu.Game.Database
modelBuilder.Entity<BeatmapSetInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<BeatmapSetInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<SkinInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<SkinInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.Hash).IsUnique();
modelBuilder.Entity<EFSkinInfo>().HasIndex(b => b.DeletePending);
modelBuilder.Entity<EFSkinInfo>().HasMany(s => s.Files).WithOne(f => f.SkinInfo);
modelBuilder.Entity<DatabasedSetting>().HasIndex(b => new { b.RulesetID, b.Variant });
+6 -51
View File
@@ -14,6 +14,7 @@ using osu.Framework.Statistics;
using osu.Game.Configuration;
using osu.Game.Input.Bindings;
using osu.Game.Models;
using osu.Game.Skinning;
using osu.Game.Stores;
using Realms;
@@ -101,10 +102,6 @@ namespace osu.Game.Database
// This method triggers the first `CreateContext` call, which will implicitly run realm migrations and bring the schema up-to-date.
cleanupPendingDeletions();
// Data migration is handled separately from schema migrations.
// This is required as the user may be initialising realm for the first time ever, which would result in no schema migrations running.
migrateDataFromEF();
}
private void cleanupPendingDeletions()
@@ -122,6 +119,11 @@ namespace osu.Game.Database
realm.Remove(s);
}
var pendingDeleteSkins = realm.All<SkinInfo>().Where(s => s.DeletePending);
foreach (var s in pendingDeleteSkins)
realm.Remove(s);
transaction.Commit();
}
@@ -193,53 +195,6 @@ namespace osu.Game.Database
};
}
private void migrateDataFromEF()
{
if (efContextFactory == null)
return;
using (var db = efContextFactory.GetForWrite())
{
// migrate ruleset settings. can be removed 20220315.
var existingSettings = db.Context.DatabasedSetting;
// previous entries in EF are removed post migration.
if (!existingSettings.Any())
return;
using (var realm = CreateContext())
using (var transaction = realm.BeginWrite())
{
// only migrate data if the realm database is empty.
if (!realm.All<RealmRulesetSetting>().Any())
{
foreach (var dkb in existingSettings)
{
if (dkb.RulesetID == null)
continue;
string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value);
if (string.IsNullOrEmpty(shortName))
continue;
realm.Add(new RealmRulesetSetting
{
Key = dkb.Key,
Value = dkb.StringValue,
RulesetName = shortName,
Variant = dkb.Variant ?? 0,
});
}
}
db.Context.RemoveRange(existingSettings);
transaction.Commit();
}
}
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
for (ulong i = lastSchemaVersion + 1; i <= schema_version; i++)
+2
View File
@@ -103,5 +103,7 @@ namespace osu.Game.Database
}
public bool Equals(ILive<T>? other) => ID == other?.ID;
public override string ToString() => PerformRead(i => i.ToString());
}
}
@@ -111,7 +111,7 @@ namespace osu.Game.Graphics.Containers
if (clock == null)
return;
double currentTrackTime = clock.CurrentTime;
double currentTrackTime = clock.CurrentTime + EarlyActivationMilliseconds;
if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded)
{
@@ -132,13 +132,11 @@ namespace osu.Game.Graphics.Containers
{
// this may be the case where the beat syncing clock has been paused.
// we still want to show an idle animation, so use this container's time instead.
currentTrackTime = Clock.CurrentTime;
currentTrackTime = Clock.CurrentTime + EarlyActivationMilliseconds;
timingPoint = TimingControlPoint.DEFAULT;
effectPoint = EffectControlPoint.DEFAULT;
}
currentTrackTime += EarlyActivationMilliseconds;
double beatLength = timingPoint.BeatLength / Divisor;
while (beatLength < MinimumBeatLength)
+2
View File
@@ -50,6 +50,8 @@ namespace osu.Game.Models
public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo;
public bool Equals(IRulesetInfo? other) => other is RealmRuleset b && Equals(b);
public override string ToString() => Name;
public RealmRuleset Clone() => new RealmRuleset
+1 -1
View File
@@ -42,7 +42,7 @@ namespace osu.Game.Online.API
if (WebRequest != null)
{
Response = ((OsuJsonWebRequest<T>)WebRequest).ResponseObject;
Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes");
Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes", LoggingTarget.Network);
}
}
@@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests
public class GetUserRequest : APIRequest<APIUser>
{
public readonly string Lookup;
public readonly RulesetInfo Ruleset;
public readonly IRulesetInfo Ruleset;
private readonly LookupType lookupType;
/// <summary>
@@ -24,7 +24,7 @@ namespace osu.Game.Online.API.Requests
/// </summary>
/// <param name="userId">The user to get.</param>
/// <param name="ruleset">The ruleset to get the user's info for.</param>
public GetUserRequest(long? userId = null, RulesetInfo ruleset = null)
public GetUserRequest(long? userId = null, IRulesetInfo ruleset = null)
{
Lookup = userId.ToString();
lookupType = LookupType.Id;
@@ -36,7 +36,7 @@ namespace osu.Game.Online.API.Requests
/// </summary>
/// <param name="username">The user to get.</param>
/// <param name="ruleset">The ruleset to get the user's info for.</param>
public GetUserRequest(string username = null, RulesetInfo ruleset = null)
public GetUserRequest(string username = null, IRulesetInfo ruleset = null)
{
Lookup = username;
lookupType = LookupType.Username;
@@ -118,7 +118,7 @@ namespace osu.Game.Online.Multiplayer
protected IAPIProvider API { get; private set; } = null!;
[Resolved]
protected RulesetStore Rulesets { get; private set; } = null!;
protected IRulesetStore Rulesets { get; private set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
@@ -733,6 +733,9 @@ namespace osu.Game.Online.Multiplayer
var apiBeatmap = await GetAPIBeatmap(item.BeatmapID).ConfigureAwait(false);
var ruleset = Rulesets.GetRuleset(item.RulesetID);
Debug.Assert(ruleset != null);
var rulesetInstance = ruleset.CreateInstance();
var playlistItem = new PlaylistItem
@@ -40,8 +40,12 @@ namespace osu.Game.Online.Rooms
public bool Expired { get; set; }
/// <summary>
/// The order in which this <see cref="MultiplayerPlaylistItem"/> will be played, starting from 0 and increasing for items which will be played later.
/// The order in which this <see cref="MultiplayerPlaylistItem"/> will be played relative to others.
/// Playlist items should be played in increasing order (lower values are played first).
/// </summary>
/// <remarks>
/// This is only valid for items which are not <see cref="Expired"/>. The value for expired items is undefined and should not be used.
/// </remarks>
[Key(8)]
public ushort PlaylistOrder { get; set; }
+4 -1
View File
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Diagnostics;
using System.Linq;
using Newtonsoft.Json;
using osu.Framework.Bindables;
@@ -85,11 +86,13 @@ namespace osu.Game.Online.Rooms
public void MarkInvalid() => valid.Value = false;
public void MapObjects(RulesetStore rulesets)
public void MapObjects(IRulesetStore rulesets)
{
Beatmap.Value ??= apiBeatmap;
Ruleset.Value ??= rulesets.GetRuleset(RulesetID);
Debug.Assert(Ruleset.Value != null);
Ruleset rulesetInstance = Ruleset.Value.CreateInstance();
if (allowedModsBacking != null)
+16 -14
View File
@@ -136,30 +136,32 @@ namespace osu.Game.Online.Spectator
public void BeginPlaying(GameplayState state, Score score)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
// This schedule is only here to match the one below in `EndPlaying`.
Schedule(() =>
{
if (IsPlaying)
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
if (IsPlaying)
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
IsPlaying = true;
IsPlaying = true;
// transfer state at point of beginning play
currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID;
currentState.RulesetID = score.ScoreInfo.RulesetID;
currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray();
// transfer state at point of beginning play
currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID;
currentState.RulesetID = score.ScoreInfo.RulesetID;
currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray();
currentBeatmap = state.Beatmap;
currentScore = score;
currentBeatmap = state.Beatmap;
currentScore = score;
BeginPlayingInternal(currentState);
BeginPlayingInternal(currentState);
});
}
public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data);
public void EndPlaying()
{
// This method is most commonly called via Dispose(), which is asynchronous.
// Todo: This should not be a thing, but requires framework changes.
// This method is most commonly called via Dispose(), which is can be asynchronous (via the AsyncDisposalQueue).
// We probably need to find a better way to handle this...
Schedule(() =>
{
if (!IsPlaying)
+11 -16
View File
@@ -161,7 +161,7 @@ namespace osu.Game
private Bindable<float> uiScale;
private Bindable<int> configSkin;
private Bindable<string> configSkin;
private readonly string[] args;
@@ -243,27 +243,22 @@ namespace osu.Game
Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName;
// bind config int to database SkinInfo
configSkin = LocalConfig.GetBindable<int>(OsuSetting.Skin);
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID;
configSkin = LocalConfig.GetBindable<string>(OsuSetting.Skin);
SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString();
configSkin.ValueChanged += skinId =>
{
var skinInfo = SkinManager.Query(s => s.ID == skinId.NewValue);
ILive<SkinInfo> skinInfo = null;
if (Guid.TryParse(skinId.NewValue, out var guid))
skinInfo = SkinManager.Query(s => s.ID == guid);
if (skinInfo == null)
{
switch (skinId.NewValue)
{
case -1:
skinInfo = DefaultLegacySkin.Info;
break;
default:
skinInfo = SkinInfo.Default;
break;
}
if (guid == SkinInfo.CLASSIC_SKIN)
skinInfo = DefaultLegacySkin.CreateInfo().ToLive();
}
SkinManager.CurrentSkinInfo.Value = skinInfo;
SkinManager.CurrentSkinInfo.Value = skinInfo ?? DefaultSkin.CreateInfo().ToLive();
};
configSkin.TriggerChange();
@@ -664,7 +659,7 @@ namespace osu.Game
// make config aware of how to lookup skins for on-screen display purposes.
// if this becomes a more common thing, tracked settings should be reconsidered to allow local DI.
LocalConfig.LookupSkinName = id => SkinManager.GetAllUsableSkins().FirstOrDefault(s => s.ID == id)?.ToString() ?? "Unknown";
LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown";
LocalConfig.LookupKeyBindings = l =>
{
+4 -9
View File
@@ -196,9 +196,12 @@ namespace osu.Game
runMigrations();
dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage));
dependencies.CacheAs<IRulesetStore>(RulesetStore);
dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client", contextFactory));
new EFToRealmMigrator(contextFactory, realmFactory, LocalConfig).Run();
dependencies.CacheAs(Storage);
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
@@ -212,17 +215,9 @@ namespace osu.Game
Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY;
dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio));
dependencies.Cache(SkinManager = new SkinManager(Storage, realmFactory, Host, Resources, Audio, Scheduler));
dependencies.CacheAs<ISkinSource>(SkinManager);
// needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo.
SkinManager.ItemRemoved += item => Schedule(() =>
{
// check the removed skin is not the current user choice. if it is, switch back to default.
if (item.Equals(SkinManager.CurrentSkinInfo.Value))
SkinManager.CurrentSkinInfo.Value = SkinInfo.Default;
});
EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration();
MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl;
@@ -47,7 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet
}
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
private void onRulesetChanged(ValueChangedEvent<IRulesetInfo> ruleset)
{
@@ -57,8 +57,13 @@ namespace osu.Game.Overlays.BeatmapSet
if (ruleset.NewValue == null)
return;
var rulesetInstance = rulesets.GetRuleset(ruleset.NewValue.OnlineID)?.CreateInstance();
if (rulesetInstance == null)
return;
modsContainer.Add(new ModButton(new ModNoMod()));
modsContainer.AddRange(rulesets.GetRuleset(ruleset.NewValue.OnlineID).CreateInstance().AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m)));
modsContainer.AddRange(rulesetInstance.AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m)));
modsContainer.ForEach(button =>
{
@@ -1,21 +1,20 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osuTK;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.API;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
namespace osu.Game.Overlays.Profile.Sections
{
@@ -24,9 +23,6 @@ namespace osu.Game.Overlays.Profile.Sections
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
protected RulesetStore Rulesets { get; private set; }
protected int VisiblePages;
protected int ItemsPerPage;
@@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent
private IAPIProvider api { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
private readonly APIRecentActivity activity;
+10 -10
View File
@@ -1,21 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Bindables;
using osu.Game.Rulesets;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.API.Requests.Responses;
using osuTK;
using osu.Framework.Allocation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Rankings.Tables;
using System.Linq;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.Rankings.Tables;
using osu.Game.Rulesets;
using osuTK;
namespace osu.Game.Overlays.Rankings
{
@@ -106,7 +106,10 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
dialogOverlay?.Push(new MassDeleteConfirmationDialog(() =>
{
deleteSkinsButton.Enabled.Value = false;
Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
Task.Run(() =>
{
skins.Delete();
}).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
}));
}
});
@@ -32,32 +32,26 @@ namespace osu.Game.Overlays.Settings.Sections
Icon = FontAwesome.Solid.PaintBrush
};
private readonly Bindable<SkinInfo> dropdownBindable = new Bindable<SkinInfo> { Default = SkinInfo.Default };
private readonly Bindable<int> configBindable = new Bindable<int>();
private readonly Bindable<ILive<SkinInfo>> dropdownBindable = new Bindable<ILive<SkinInfo>> { Default = DefaultSkin.CreateInfo().ToLive() };
private readonly Bindable<string> configBindable = new Bindable<string>();
private static readonly SkinInfo random_skin_info = new SkinInfo
private static readonly ILive<SkinInfo> random_skin_info = new SkinInfo
{
ID = SkinInfo.RANDOM_SKIN,
Name = "<Random Skin>",
};
}.ToLive();
private List<SkinInfo> skinItems;
private int firstNonDefaultSkinIndex
{
get
{
int index = skinItems.FindIndex(s => s.ID > 0);
if (index < 0)
index = skinItems.Count;
return index;
}
}
private List<ILive<SkinInfo>> skinItems;
[Resolved]
private SkinManager skins { get; set; }
[Resolved]
private RealmContextFactory realmFactory { get; set; }
private IDisposable realmSubscription;
private IQueryable<SkinInfo> realmSkins;
[BackgroundDependencyLoader(permitNulls: true)]
private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor)
{
@@ -75,96 +69,95 @@ namespace osu.Game.Overlays.Settings.Sections
new ExportSkinButton(),
};
skins.ItemUpdated += itemUpdated;
skins.ItemRemoved += itemRemoved;
config.BindWith(OsuSetting.Skin, configBindable);
}
protected override void LoadComplete()
{
base.LoadComplete();
skinDropdown.Current = dropdownBindable;
realmSkins = realmFactory.Context.All<SkinInfo>()
.Where(s => !s.DeletePending)
.OrderByDescending(s => s.Protected) // protected skins should be at the top.
.ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase);
realmSubscription = realmSkins
.QueryAsyncWithNotifications((sender, changes, error) =>
{
if (changes == null)
return;
// Eventually this should be handling the individual changes rather than refreshing the whole dropdown.
updateItems();
});
updateItems();
// Todo: This should not be necessary when OsuConfigManager is databased
if (skinDropdown.Items.All(s => s.ID != configBindable.Value))
configBindable.Value = 0;
configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig));
updateSelectedSkinFromConfig();
configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig), true);
dropdownBindable.BindValueChanged(skin =>
{
if (skin.NewValue == random_skin_info)
if (skin.NewValue.Equals(random_skin_info))
{
var skinBefore = skins.CurrentSkinInfo.Value;
skins.SelectRandomSkin();
if (skinBefore == skins.CurrentSkinInfo.Value)
{
// the random selection didn't change the skin, so we should manually update the dropdown to match.
dropdownBindable.Value = skins.CurrentSkinInfo.Value;
}
return;
}
configBindable.Value = skin.NewValue.ID;
configBindable.Value = skin.NewValue.ID.ToString();
});
}
private void updateSelectedSkinFromConfig()
{
int id = configBindable.Value;
ILive<SkinInfo> skin = null;
var skin = skinDropdown.Items.FirstOrDefault(s => s.ID == id);
if (Guid.TryParse(configBindable.Value, out var configId))
skin = skinDropdown.Items.FirstOrDefault(s => s.ID == configId);
if (skin == null)
{
// there may be a thread race condition where an item is selected that hasn't yet been added to the dropdown.
// to avoid adding complexity, let's just ensure the item is added so we can perform the selection.
skin = skins.Query(s => s.ID == id);
addItem(skin);
}
dropdownBindable.Value = skin;
dropdownBindable.Value = skin ?? skinDropdown.Items.First();
}
private void updateItems()
{
skinItems = skins.GetAllUsableSkins();
skinItems.Insert(firstNonDefaultSkinIndex, random_skin_info);
sortUserSkins(skinItems);
int protectedCount = realmSkins.Count(s => s.Protected);
skinItems = realmSkins.ToLive();
skinItems.Insert(protectedCount, random_skin_info);
skinDropdown.Items = skinItems;
}
private void itemUpdated(SkinInfo item) => Schedule(() => addItem(item));
private void addItem(SkinInfo item)
{
List<SkinInfo> newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList();
sortUserSkins(newDropdownItems);
skinDropdown.Items = newDropdownItems;
}
private void itemRemoved(SkinInfo item) => Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => !i.Equals(item)).ToArray());
private void sortUserSkins(List<SkinInfo> skinsList)
{
// Sort user skins separately from built-in skins
skinsList.Sort(firstNonDefaultSkinIndex, skinsList.Count - firstNonDefaultSkinIndex,
Comparer<SkinInfo>.Create((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)));
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (skins != null)
{
skins.ItemUpdated -= itemUpdated;
skins.ItemRemoved -= itemRemoved;
}
realmSubscription?.Dispose();
}
private class SkinSettingsDropdown : SettingsDropdown<SkinInfo>
private class SkinSettingsDropdown : SettingsDropdown<ILive<SkinInfo>>
{
protected override OsuDropdown<SkinInfo> CreateDropdown() => new SkinDropdownControl();
protected override OsuDropdown<ILive<SkinInfo>> CreateDropdown() => new SkinDropdownControl();
private class SkinDropdownControl : DropdownControl
{
protected override LocalisableString GenerateItemText(SkinInfo item) => item.ToString();
protected override LocalisableString GenerateItemText(ILive<SkinInfo> item) => item.ToString();
}
}
private class ExportSkinButton : SettingsButton
public class ExportSkinButton : SettingsButton
{
[Resolved]
private SkinManager skins { get; set; }
@@ -179,16 +172,21 @@ namespace osu.Game.Overlays.Settings.Sections
{
Text = SkinSettingsStrings.ExportSkinButton;
Action = export;
}
protected override void LoadComplete()
{
base.LoadComplete();
currentSkin = skins.CurrentSkin.GetBoundCopy();
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.ID > 0, true);
currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true);
}
private void export()
{
try
{
new LegacySkinExporter(storage).Export(currentSkin.Value.SkinInfo);
currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s));
}
catch (Exception e)
{
@@ -120,14 +120,14 @@ namespace osu.Game.Rulesets.Difficulty
/// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap.
/// </summary>
/// <returns>A collection of structures describing the difficulty of the beatmap for each mod combination.</returns>
public IEnumerable<DifficultyAttributes> CalculateAll()
public IEnumerable<DifficultyAttributes> CalculateAll(CancellationToken cancellationToken = default)
{
foreach (var combination in CreateDifficultyAdjustmentModCombinations())
{
if (combination is MultiMod multi)
yield return Calculate(multi.Mods);
yield return Calculate(multi.Mods, cancellationToken);
else
yield return Calculate(combination.Yield());
yield return Calculate(combination.Yield(), cancellationToken);
}
}
@@ -145,7 +145,11 @@ namespace osu.Game.Rulesets.Difficulty
{
playableMods = mods.Select(m => m.DeepClone()).ToArray();
Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken);
// Only pass through the cancellation token if it's non-default.
// This allows for the default timeout to be applied for playable beatmap construction.
Beatmap = cancellationToken == default
? beatmap.GetPlayableBeatmap(ruleset, playableMods)
: beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken);
var track = new TrackVirtual(10000);
playableMods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
+2 -1
View File
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Database;
#nullable enable
@@ -10,7 +11,7 @@ namespace osu.Game.Rulesets
/// <summary>
/// A representation of a ruleset's metadata.
/// </summary>
public interface IRulesetInfo : IHasOnlineID<int>
public interface IRulesetInfo : IHasOnlineID<int>, IEquatable<IRulesetInfo>
{
/// <summary>
/// The user-exposed name of this ruleset.
+31
View File
@@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
#nullable enable
namespace osu.Game.Rulesets
{
public interface IRulesetStore
{
/// <summary>
/// Retrieve a ruleset using a known ID.
/// </summary>
/// <param name="id">The ruleset's internal ID.</param>
/// <returns>A ruleset, if available, else null.</returns>
IRulesetInfo? GetRuleset(int id);
/// <summary>
/// Retrieve a ruleset using a known short name.
/// </summary>
/// <param name="shortName">The ruleset's short name.</param>
/// <returns>A ruleset, if available, else null.</returns>
IRulesetInfo? GetRuleset(string shortName);
/// <summary>
/// All available rulesets.
/// </summary>
IEnumerable<IRulesetInfo> AvailableRulesets { get; }
}
}
+2
View File
@@ -49,6 +49,8 @@ namespace osu.Game.Rulesets
public override bool Equals(object obj) => obj is RulesetInfo rulesetInfo && Equals(rulesetInfo);
public bool Equals(IRulesetInfo other) => other is RulesetInfo b && Equals(b);
[SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")]
public override int GetHashCode()
{
+9 -1
View File
@@ -13,7 +13,7 @@ using osu.Game.Database;
namespace osu.Game.Rulesets
{
public class RulesetStore : DatabaseBackedStore, IDisposable
public class RulesetStore : DatabaseBackedStore, IRulesetStore, IDisposable
{
private const string ruleset_library_prefix = "osu.Game.Rulesets";
@@ -236,5 +236,13 @@ namespace osu.Game.Rulesets
{
AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly;
}
#region Implementation of IRulesetStore
IRulesetInfo IRulesetStore.GetRuleset(int id) => GetRuleset(id);
IRulesetInfo IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName);
IEnumerable<IRulesetInfo> IRulesetStore.AvailableRulesets => AvailableRulesets;
#endregion
}
}
@@ -46,7 +46,7 @@ namespace osu.Game.Scoring.Legacy
sw.Write(LATEST_VERSION);
sw.Write(score.ScoreInfo.BeatmapInfo.MD5Hash);
sw.Write(score.ScoreInfo.UserString);
sw.Write($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}".ComputeMD5Hash());
sw.Write(FormattableString.Invariant($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}").ComputeMD5Hash());
sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0));
sw.Write((ushort)(score.ScoreInfo.GetCount100() ?? 0));
sw.Write((ushort)(score.ScoreInfo.GetCount50() ?? 0));
@@ -110,7 +110,9 @@ namespace osu.Game.Scoring.Legacy
}
}
replayData.AppendFormat(@"{0}|{1}|{2}|{3},", -12345, 0, 0, 0);
// Warning: this is purposefully hardcoded as a string rather than interpolating, as in some cultures the minus sign is not encoded as the standard ASCII U+00C2 codepoint,
// which then would break decoding.
replayData.Append(@"-12345|0|0|0");
return replayData.ToString();
}
}
@@ -27,7 +27,7 @@ namespace osu.Game.Screens.OnlinePlay.Components
private readonly Bindable<Room> joinedRoom = new Bindable<Room>();
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
[Resolved]
private IAPIProvider api { get; set; }
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@@ -184,20 +185,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Children = new Drawable[]
{
new PlaylistCountPill
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new StarRatingRangeDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.8f)
}
}
ChildrenEnumerable = CreateBottomDetails()
}
}
},
@@ -287,6 +275,37 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
protected virtual Drawable CreateBackground() => new OnlinePlayBackgroundSprite();
protected virtual IEnumerable<Drawable> CreateBottomDetails()
{
var pills = new List<Drawable>();
if (Room.Type.Value != MatchType.Playlists)
{
pills.AddRange(new OnlinePlayComposite[]
{
new MatchTypePill(),
new QueueModePill(),
});
}
pills.AddRange(new Drawable[]
{
new PlaylistCountPill
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
new StarRatingRangeDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Scale = new Vector2(0.8f)
}
});
return pills;
}
private class RoomNameText : OsuSpriteText
{
[Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))]
@@ -0,0 +1,50 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Online.Rooms;
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
public class MatchTypePill : OnlinePlayComposite
{
private OsuTextFlowContainer textFlow;
public MatchTypePill()
{
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new PillContainer
{
Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Type.BindValueChanged(onMatchTypeChanged, true);
}
private void onMatchTypeChanged(ValueChangedEvent<MatchType> type)
{
textFlow.Clear();
textFlow.AddText(type.NewValue.GetLocalisableDescription());
}
}
}
@@ -0,0 +1,50 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Online.Multiplayer;
namespace osu.Game.Screens.OnlinePlay.Lounge.Components
{
public class QueueModePill : OnlinePlayComposite
{
private OsuTextFlowContainer textFlow;
public QueueModePill()
{
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new PillContainer
{
Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12))
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
AutoSizeAxes = Axes.Both,
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
QueueMode.BindValueChanged(onQueueModeChanged, true);
}
private void onQueueModeChanged(ValueChangedEvent<QueueMode> mode)
{
textFlow.Clear();
textFlow.AddText(mode.NewValue.GetLocalisableDescription());
}
}
}
@@ -319,6 +319,16 @@ namespace osu.Game.Screens.OnlinePlay.Match
protected void StartPlay()
{
// User may be at song select or otherwise when the host starts gameplay.
// Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state.
if (!this.IsCurrentScreen())
{
this.MakeCurrent();
Schedule(StartPlay);
return;
}
sampleStart?.Play();
// fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes).
@@ -138,11 +138,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
RelativeSizeAxes = Axes.X,
Height = 40,
Action = () =>
{
if (this.IsCurrentScreen())
this.Push(new MultiplayerMatchSongSelect(Room));
},
Action = SelectBeatmap,
Alpha = 0
},
},
@@ -224,6 +220,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
};
internal void SelectBeatmap()
{
if (!this.IsCurrentScreen())
return;
this.Push(new MultiplayerMatchSongSelect(Room));
}
protected override Drawable CreateFooter() => new MultiplayerMatchFooter
{
OnReadyClick = onReadyClick,
@@ -32,9 +32,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated);
private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.AddOnce(UserJoined, user);
private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(UserKicked, user);
private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.AddOnce(UserLeft, user);
private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => UserJoined(user));
private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.Add(() => UserKicked(user));
private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => UserLeft(user));
private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item));
private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item));
private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item));
@@ -35,7 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
private IAPIProvider api { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
private IRulesetStore rulesets { get; set; }
private SpriteIcon crown;
@@ -185,9 +185,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
const double fade_time = 50;
// Todo: Should use the room's selected item to determine ruleset.
var ruleset = rulesets.GetRuleset(0).CreateInstance();
var ruleset = rulesets.GetRuleset(0)?.CreateInstance();
int? currentModeRank = User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank;
int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null;
userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty;
userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability);
@@ -77,7 +77,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
else
{
// Remove panels for users no longer in the room.
panels.RemoveAll(p => !Room.Users.Contains(p.User));
foreach (var p in panels)
{
// Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run.
if (Room.Users.All(u => !ReferenceEquals(p.User, u)))
p.Expire();
}
// Add panels for all users new to the room.
foreach (var user in Room.Users.Except(panels.Select(p => p.User)))
-1
View File
@@ -27,7 +27,6 @@ using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.Spectate;
using osu.Game.Users;
using osuTK;
using APIUser = osu.Game.Online.API.Requests.Responses.APIUser;
namespace osu.Game.Screens.Play
{
+11 -10
View File
@@ -12,8 +12,17 @@ namespace osu.Game.Skinning
{
public class DefaultLegacySkin : LegacySkin
{
public static SkinInfo CreateInfo() => new SkinInfo
{
ID = Skinning.SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon.
Name = "osu!classic",
Creator = "team osu!",
Protected = true,
InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo()
};
public DefaultLegacySkin(IStorageResourceProvider resources)
: this(Info, resources)
: this(CreateInfo(), resources)
{
}
@@ -25,7 +34,7 @@ namespace osu.Game.Skinning
resources,
// A default legacy skin may still have a skin.ini if it is modified by the user.
// We must specify the stream directly as we are redirecting storage to the osu-resources location for other files.
new LegacySkinResourceStore<SkinFileInfo>(skin, resources.Files).GetStream("skin.ini")
new LegacyDatabasedSkinResourceStore(skin, resources.Files).GetStream("skin.ini")
)
{
Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255);
@@ -39,13 +48,5 @@ namespace osu.Game.Skinning
Configuration.LegacyVersion = 2.7m;
}
public static SkinInfo Info { get; } = new SkinInfo
{
ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon.
Name = "osu!classic",
Creator = "team osu!",
InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo()
};
}
}
+10 -1
View File
@@ -23,10 +23,19 @@ namespace osu.Game.Skinning
{
public class DefaultSkin : Skin
{
public static SkinInfo CreateInfo() => new SkinInfo
{
ID = osu.Game.Skinning.SkinInfo.DEFAULT_SKIN,
Name = "osu! (triangles)",
Creator = "team osu!",
Protected = true,
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
};
private readonly IStorageResourceProvider resources;
public DefaultSkin(IStorageResourceProvider resources)
: this(SkinInfo.Default, resources)
: this(CreateInfo(), resources)
{
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
namespace osu.Game.Skinning
{
[Table(@"SkinInfo")]
public class EFSkinInfo : IHasFiles<SkinFileInfo>, IEquatable<EFSkinInfo>, IHasPrimaryKey, ISoftDelete
{
internal const int DEFAULT_SKIN = 0;
internal const int CLASSIC_SKIN = -1;
internal const int RANDOM_SKIN = -2;
public int ID { get; set; }
public string Name { get; set; } = string.Empty;
public string Creator { get; set; } = string.Empty;
public string Hash { get; set; }
public string InstantiationInfo { get; set; }
public virtual Skin CreateInstance(IStorageResourceProvider resources)
{
var type = string.IsNullOrEmpty(InstantiationInfo)
// handle the case of skins imported before InstantiationInfo was added.
? typeof(LegacySkin)
: Type.GetType(InstantiationInfo).AsNonNull();
return (Skin)Activator.CreateInstance(type, this, resources);
}
public List<SkinFileInfo> Files { get; set; } = new List<SkinFileInfo>();
public bool DeletePending { get; set; }
public static EFSkinInfo Default { get; } = new EFSkinInfo
{
ID = DEFAULT_SKIN,
Name = "osu! (triangles)",
Creator = "team osu!",
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
};
public bool Equals(EFSkinInfo other) => other != null && ID == other.ID;
public override string ToString()
{
string author = Creator == null ? string.Empty : $"({Creator})";
return $"{Name} {author}".Trim();
}
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ namespace osu.Game.Skinning
protected override bool UseCustomSampleBanks => true;
public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, IResourceStore<byte[]> storage, IStorageResourceProvider resources)
: base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore<BeatmapSetFileInfo>(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path)
: base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path)
{
// Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer)
Configuration.AllowDefaultComboColoursFallback = false;
@@ -77,6 +77,6 @@ namespace osu.Game.Skinning
}
private static SkinInfo createSkinInfo(BeatmapInfo beatmapInfo) =>
new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username };
new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username ?? string.Empty };
}
}
@@ -0,0 +1,43 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Framework.Extensions;
using osu.Framework.IO.Stores;
using osu.Game.Extensions;
namespace osu.Game.Skinning
{
public class LegacyDatabasedSkinResourceStore : ResourceStore<byte[]>
{
private readonly Dictionary<string, string> fileToStoragePathMapping = new Dictionary<string, string>();
public LegacyDatabasedSkinResourceStore(SkinInfo source, IResourceStore<byte[]> underlyingStore)
: base(underlyingStore)
{
initialiseFileCache(source);
}
private void initialiseFileCache(SkinInfo source)
{
fileToStoragePathMapping.Clear();
foreach (var f in source.Files)
fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath();
}
protected override IEnumerable<string> GetFilenames(string name)
{
foreach (string filename in base.GetFilenames(name))
{
string path = getPathForFile(filename.ToStandardisedPath());
if (path != null)
yield return path;
}
}
private string getPathForFile(string filename) =>
fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null;
public override IEnumerable<string> GetAvailableResources() => fileToStoragePathMapping.Keys;
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ namespace osu.Game.Skinning
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)]
public LegacySkin(SkinInfo skin, IStorageResourceProvider resources)
: this(skin, new LegacySkinResourceStore<SkinFileInfo>(skin, resources.Files), resources, "skin.ini")
: this(skin, new LegacyDatabasedSkinResourceStore(skin, resources.Files), resources, "skin.ini")
{
}
+4 -5
View File
@@ -11,12 +11,11 @@ using osu.Game.Extensions;
namespace osu.Game.Skinning
{
public class LegacySkinResourceStore<T> : ResourceStore<byte[]>
where T : INamedFileInfo
public class LegacySkinResourceStore : ResourceStore<byte[]>
{
private readonly IHasFiles<T> source;
private readonly IHasNamedFiles source;
public LegacySkinResourceStore(IHasFiles<T> source, IResourceStore<byte[]> underlyingStore)
public LegacySkinResourceStore(IHasNamedFiles source, IResourceStore<byte[]> underlyingStore)
: base(underlyingStore)
{
this.source = source;
@@ -33,7 +32,7 @@ namespace osu.Game.Skinning
}
private string getPathForFile(string filename) =>
source.Files.Find(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath();
source.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath();
public override IEnumerable<string> GetAvailableResources() => source.Files.Select(f => f.Filename);
}
+33 -28
View File
@@ -15,6 +15,7 @@ using osu.Framework.Graphics.OpenGL.Textures;
using osu.Framework.Graphics.Textures;
using osu.Framework.Logging;
using osu.Game.Audio;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Screens.Play.HUD;
@@ -23,7 +24,7 @@ namespace osu.Game.Skinning
{
public abstract class Skin : IDisposable, ISkin
{
public readonly SkinInfo SkinInfo;
public readonly ILive<SkinInfo> SkinInfo;
private readonly IStorageResourceProvider resources;
public SkinConfiguration Configuration { get; set; }
@@ -42,7 +43,7 @@ namespace osu.Game.Skinning
protected Skin(SkinInfo skin, IStorageResourceProvider resources, [CanBeNull] Stream configurationStream = null)
{
SkinInfo = skin;
SkinInfo = skin.ToLive();
this.resources = resources;
configurationStream ??= getConfigurationStream();
@@ -53,37 +54,41 @@ namespace osu.Game.Skinning
else
Configuration = new SkinConfiguration();
// we may want to move this to some kind of async operation in the future.
foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
// skininfo files may be null for default skin.
SkinInfo.PerformRead(s =>
{
string filename = $"{skinnableTarget}.json";
// skininfo files may be null for default skin.
var fileInfo = SkinInfo.Files.FirstOrDefault(f => f.Filename == filename);
if (fileInfo == null)
continue;
byte[] bytes = resources?.Files.Get(fileInfo.FileInfo.GetStoragePath());
if (bytes == null)
continue;
try
// we may want to move this to some kind of async operation in the future.
foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget)))
{
string jsonContent = Encoding.UTF8.GetString(bytes);
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
string filename = $"{skinnableTarget}.json";
if (deserializedContent == null)
// skininfo files may be null for default skin.
var fileInfo = s.Files.FirstOrDefault(f => f.Filename == filename);
if (fileInfo == null)
continue;
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
byte[] bytes = resources?.Files.Get(fileInfo.File.GetStoragePath());
if (bytes == null)
continue;
try
{
string jsonContent = Encoding.UTF8.GetString(bytes);
var deserializedContent = JsonConvert.DeserializeObject<IEnumerable<SkinnableInfo>>(jsonContent);
if (deserializedContent == null)
continue;
DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load skin configuration.");
}
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load skin configuration.");
}
}
});
}
protected virtual void ParseConfigurationStream(Stream stream)
@@ -94,7 +99,7 @@ namespace osu.Game.Skinning
private Stream getConfigurationStream()
{
string path = SkinInfo.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath();
string path = SkinInfo.PerformRead(s => s.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath());
if (string.IsNullOrEmpty(path))
return null;
+2
View File
@@ -13,6 +13,8 @@ namespace osu.Game.Skinning
public int SkinInfoID { get; set; }
public EFSkinInfo SkinInfo { get; set; }
public int FileInfoID { get; set; }
public FileInfo FileInfo { get; set; }
+31 -18
View File
@@ -1,30 +1,45 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Models;
using Realms;
#nullable enable
namespace osu.Game.Skinning
{
public class SkinInfo : IHasFiles<SkinFileInfo>, IEquatable<SkinInfo>, IHasPrimaryKey, ISoftDelete, IHasNamedFiles
[ExcludeFromDynamicCompile]
[MapTo("Skin")]
[JsonObject(MemberSerialization.OptIn)]
public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable<SkinInfo>, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles
{
internal const int DEFAULT_SKIN = 0;
internal const int CLASSIC_SKIN = -1;
internal const int RANDOM_SKIN = -2;
internal static readonly Guid DEFAULT_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD");
internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187");
internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908");
public int ID { get; set; }
[PrimaryKey]
[JsonProperty]
public Guid ID { get; set; } = Guid.NewGuid();
[JsonProperty]
public string Name { get; set; } = string.Empty;
[JsonProperty]
public string Creator { get; set; } = string.Empty;
public string Hash { get; set; }
[JsonProperty]
public string InstantiationInfo { get; set; } = string.Empty;
public string InstantiationInfo { get; set; }
public string Hash { get; set; } = string.Empty;
public bool Protected { get; set; }
public virtual Skin CreateInstance(IStorageResourceProvider resources)
{
@@ -36,23 +51,21 @@ namespace osu.Game.Skinning
return (Skin)Activator.CreateInstance(type, this, resources);
}
public List<SkinFileInfo> Files { get; } = new List<SkinFileInfo>();
public IList<RealmNamedFileUsage> Files { get; } = null!;
public bool DeletePending { get; set; }
public static SkinInfo Default { get; } = new SkinInfo
public bool Equals(SkinInfo? other)
{
ID = DEFAULT_SKIN,
Name = "osu! (triangles)",
Creator = "team osu!",
InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo()
};
if (ReferenceEquals(this, other)) return true;
if (other == null) return false;
public bool Equals(SkinInfo other) => other != null && ID == other.ID;
return ID == other.ID;
}
public override string ToString()
{
string author = Creator == null ? string.Empty : $"({Creator})";
string author = string.IsNullOrEmpty(Creator) ? string.Empty : $"({Creator})";
return $"{Name} {author}".Trim();
}
+90 -113
View File
@@ -3,14 +3,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using JetBrains.Annotations;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
@@ -20,6 +17,7 @@ using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Database;
@@ -37,20 +35,25 @@ namespace osu.Game.Skinning
/// For gameplay components, see <see cref="RulesetSkinProvidingContainer"/> which adds extra legacy and toggle logic that may affect the lookup process.
/// </remarks>
[ExcludeFromDynamicCompile]
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>, IModelManager<SkinInfo>
public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter<SkinInfo>
{
private readonly AudioManager audio;
private readonly Scheduler scheduler;
private readonly GameHost host;
private readonly IResourceStore<byte[]> resources;
public readonly Bindable<Skin> CurrentSkin = new Bindable<Skin>();
public readonly Bindable<SkinInfo> CurrentSkinInfo = new Bindable<SkinInfo>(SkinInfo.Default) { Default = SkinInfo.Default };
public readonly Bindable<ILive<SkinInfo>> CurrentSkinInfo = new Bindable<ILive<SkinInfo>>(Skinning.DefaultSkin.CreateInfo().ToLive())
{
Default = Skinning.DefaultSkin.CreateInfo().ToLive()
};
private readonly SkinModelManager skinModelManager;
private readonly SkinStore skinStore;
private readonly RealmContextFactory contextFactory;
private readonly IResourceStore<byte[]> userFiles;
@@ -64,69 +67,66 @@ namespace osu.Game.Skinning
/// </summary>
public Skin DefaultLegacySkin { get; }
public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore<byte[]> resources, AudioManager audio)
public SkinManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IResourceStore<byte[]> resources, AudioManager audio, Scheduler scheduler)
{
this.contextFactory = contextFactory;
this.audio = audio;
this.scheduler = scheduler;
this.host = host;
this.resources = resources;
skinStore = new SkinStore(contextFactory, storage);
userFiles = new FileStore(contextFactory, storage).Store;
userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files"));
skinModelManager = new SkinModelManager(storage, contextFactory, skinStore, host, this);
skinModelManager = new SkinModelManager(storage, contextFactory, host, this);
DefaultLegacySkin = new DefaultLegacySkin(this);
DefaultSkin = new DefaultSkin(this);
var defaultSkins = new[]
{
DefaultLegacySkin = new DefaultLegacySkin(this),
DefaultSkin = new DefaultSkin(this),
};
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue);
// Ensure the default entries are present.
using (var context = contextFactory.CreateContext())
using (var transaction = context.BeginWrite())
{
foreach (var skin in defaultSkins)
{
if (context.Find<SkinInfo>(skin.SkinInfo.ID) == null)
context.Add(skin.SkinInfo.Value);
}
transaction.Commit();
}
CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin);
CurrentSkin.Value = DefaultSkin;
CurrentSkin.ValueChanged += skin =>
{
if (skin.NewValue.SkinInfo != CurrentSkinInfo.Value)
if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value))
throw new InvalidOperationException($"Setting {nameof(CurrentSkin)}'s value directly is not supported. Use {nameof(CurrentSkinInfo)} instead.");
SourceChanged?.Invoke();
};
}
/// <summary>
/// Returns a list of all usable <see cref="SkinInfo"/>s. Includes the special default skin plus all skins from <see cref="GetAllUserSkins"/>.
/// </summary>
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
public List<SkinInfo> GetAllUsableSkins()
{
var userSkins = GetAllUserSkins();
userSkins.Insert(0, DefaultSkin.SkinInfo);
userSkins.Insert(1, DefaultLegacySkin.SkinInfo);
return userSkins;
}
/// <summary>
/// Returns a list of all usable <see cref="SkinInfo"/>s that have been loaded by the user.
/// </summary>
/// <returns>A newly allocated list of available <see cref="SkinInfo"/>.</returns>
public List<SkinInfo> GetAllUserSkins(bool includeFiles = false)
{
if (includeFiles)
return skinStore.ConsumableItems.Where(s => !s.DeletePending).ToList();
return skinStore.Items.Where(s => !s.DeletePending).ToList();
}
public void SelectRandomSkin()
{
// choose from only user skins, removing the current selection to ensure a new one is chosen.
var randomChoices = skinStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray();
if (randomChoices.Length == 0)
using (var context = contextFactory.CreateContext())
{
CurrentSkinInfo.Value = SkinInfo.Default;
return;
}
// choose from only user skins, removing the current selection to ensure a new one is chosen.
var randomChoices = context.All<SkinInfo>().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray();
var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length));
CurrentSkinInfo.Value = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID);
if (randomChoices.Length == 0)
{
CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive();
return;
}
var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length));
CurrentSkinInfo.Value = chosen.ToLive();
}
}
/// <summary>
@@ -142,40 +142,36 @@ namespace osu.Game.Skinning
/// </summary>
public void EnsureMutableSkin()
{
if (CurrentSkinInfo.Value.ID >= 1) return;
var skin = CurrentSkin.Value;
// if the user is attempting to save one of the default skin implementations, create a copy first.
CurrentSkinInfo.Value = skinModelManager.Import(new SkinInfo
CurrentSkinInfo.Value.PerformRead(s =>
{
Name = skin.SkinInfo.Name + @" (modified)",
Creator = skin.SkinInfo.Creator,
InstantiationInfo = skin.SkinInfo.InstantiationInfo,
}).Result.Value;
if (!s.Protected)
return;
// if the user is attempting to save one of the default skin implementations, create a copy first.
var result = skinModelManager.Import(new SkinInfo
{
Name = s.Name + @" (modified)",
Creator = s.Creator,
InstantiationInfo = s.InstantiationInfo,
}).Result;
if (result != null)
{
// save once to ensure the required json content is populated.
// currently this only happens on save.
result.PerformRead(skin => Save(skin.CreateInstance(this)));
CurrentSkinInfo.Value = result;
}
});
}
public void Save(Skin skin)
{
if (skin.SkinInfo.ID <= 0)
if (!skin.SkinInfo.IsManaged)
throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first.");
foreach (var drawableInfo in skin.DrawableComponentInfo)
{
string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented });
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json)))
{
string filename = @$"{drawableInfo.Key}.json";
var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename);
if (oldFile != null)
skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent);
else
skinModelManager.AddFile(skin.SkinInfo, streamContent, filename);
}
}
skinModelManager.Save(skin);
}
/// <summary>
@@ -183,7 +179,11 @@ namespace osu.Game.Skinning
/// </summary>
/// <param name="query">The query.</param>
/// <returns>The first result for the provided query, or null if no results were found.</returns>
public SkinInfo Query(Expression<Func<SkinInfo, bool>> query) => skinStore.ConsumableItems.AsNoTracking().FirstOrDefault(query);
public ILive<SkinInfo> Query(Expression<Func<SkinInfo, bool>> query)
{
using (var context = contextFactory.CreateContext())
return context.All<SkinInfo>().FirstOrDefault(query)?.ToLive();
}
public event Action SourceChanged;
@@ -289,46 +289,23 @@ namespace osu.Game.Skinning
#region Implementation of IModelManager<SkinInfo>
public event Action<SkinInfo> ItemUpdated
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)
{
add => skinModelManager.ItemUpdated += value;
remove => skinModelManager.ItemUpdated -= value;
}
using (var context = contextFactory.CreateContext())
{
var items = context.All<SkinInfo>()
.Where(s => !s.Protected && !s.DeletePending);
if (filter != null)
items = items.Where(filter);
public event Action<SkinInfo> ItemRemoved
{
add => skinModelManager.ItemRemoved += value;
remove => skinModelManager.ItemRemoved -= value;
}
// check the removed skin is not the current user choice. if it is, switch back to default.
Guid currentUserSkin = CurrentSkinInfo.Value.ID;
public void Update(SkinInfo item)
{
skinModelManager.Update(item);
}
if (items.Any(s => s.ID == currentUserSkin))
scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLive());
public bool Delete(SkinInfo item)
{
return skinModelManager.Delete(item);
}
public void Delete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Delete(items, silent);
}
public void Undelete(List<SkinInfo> items, bool silent = false)
{
skinModelManager.Undelete(items, silent);
}
public void Undelete(SkinInfo item)
{
skinModelManager.Undelete(item);
}
public bool IsAvailableLocally(SkinInfo model)
{
return skinModelManager.IsAvailableLocally(model);
skinModelManager.Delete(items.ToList(), silent);
}
}
#endregion
+130 -53
View File
@@ -8,21 +8,28 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Stores;
using Realms;
#nullable enable
namespace osu.Game.Skinning
{
public class SkinModelManager : ArchiveModelManager<SkinInfo, SkinFileInfo>
public class SkinModelManager : RealmArchiveModelManager<SkinInfo>
{
private const string skin_info_file = "skininfo.json";
private readonly IStorageResourceProvider skinResources;
public SkinModelManager(Storage storage, DatabaseContextFactory contextFactory, SkinStore skinStore, GameHost host, IStorageResourceProvider skinResources)
: base(storage, contextFactory, skinStore, host)
public SkinModelManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IStorageResourceProvider skinResources)
: base(storage, contextFactory)
{
this.skinResources = skinResources;
@@ -42,18 +49,55 @@ namespace osu.Game.Skinning
protected override bool HasCustomHashFunction => true;
protected override string ComputeHash(SkinInfo item)
protected override Task Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
{
var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file);
if (skinInfoFile != null)
{
try
{
using (var existingStream = Files.Storage.GetStream(skinInfoFile.File.GetStoragePath()))
using (var reader = new StreamReader(existingStream))
{
var deserialisedSkinInfo = JsonConvert.DeserializeObject<SkinInfo>(reader.ReadToEnd());
if (deserialisedSkinInfo != null)
{
// for now we only care about the instantiation info.
// eventually we probably want to transfer everything across.
model.InstantiationInfo = deserialisedSkinInfo.InstantiationInfo;
}
}
}
catch (Exception e)
{
LogForModel(model, $"Error during {skin_info_file} parsing, falling back to default", e);
// Not sure if we should still run the import in the case of failure here, but let's do so for now.
model.InstantiationInfo = string.Empty;
}
}
// Always rewrite instantiation info (even after parsing in from the skin json) for sanity.
model.InstantiationInfo = createInstance(model).GetType().GetInvariantInstantiationInfo();
checkSkinIniMetadata(model, realm);
return Task.CompletedTask;
}
private void checkSkinIniMetadata(SkinInfo item, Realm realm)
{
var instance = createInstance(item);
// This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations.
// `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above.
string skinIniSourcedName = instance.Configuration.SkinInfo.Name;
string skinIniSourcedCreator = instance.Configuration.SkinInfo.Creator;
string archiveName = item.Name.Replace(@".osk", string.Empty, StringComparison.OrdinalIgnoreCase);
bool isImport = item.ID == 0;
bool isImport = !item.IsManaged;
if (isImport)
{
@@ -71,12 +115,10 @@ namespace osu.Game.Skinning
// Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching.
// This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place.
if (skinIniSourcedName != item.Name)
updateSkinIniMetadata(item);
return base.ComputeHash(item);
updateSkinIniMetadata(item, realm);
}
private void updateSkinIniMetadata(SkinInfo item)
private void updateSkinIniMetadata(SkinInfo item, Realm realm)
{
string nameLine = @$"Name: {item.Name}";
string authorLine = @$"Author: {item.Creator}";
@@ -95,39 +137,47 @@ namespace osu.Game.Skinning
{
// In the case a skin doesn't have a skin.ini yet, let's create one.
writeNewSkinIni();
return;
}
using (Stream stream = new MemoryStream())
else
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
using (Stream stream = new MemoryStream())
{
using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath()))
using (var sr = new StreamReader(existingStream))
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
string line;
while ((line = sr.ReadLine()) != null)
using (var existingStream = Files.Storage.GetStream(existingFile.File.GetStoragePath()))
using (var sr = new StreamReader(existingStream))
{
string? line;
while ((line = sr.ReadLine()) != null)
sw.WriteLine(line);
}
sw.WriteLine();
foreach (string line in newLines)
sw.WriteLine(line);
}
sw.WriteLine();
ReplaceFile(existingFile, stream, realm);
foreach (string line in newLines)
sw.WriteLine(line);
}
// can be removed 20220502.
if (!ensureIniWasUpdated(item))
{
Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important);
ReplaceFile(item, existingFile, stream);
var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase));
if (existingIni != null)
item.Files.Remove(existingIni);
// can be removed 20220502.
if (!ensureIniWasUpdated(item))
{
Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important);
DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)));
writeNewSkinIni();
writeNewSkinIni();
}
}
}
// The hash is already populated at this point in import.
// As we have changed files, it needs to be recomputed.
item.Hash = ComputeHash(item);
void writeNewSkinIni()
{
using (Stream stream = new MemoryStream())
@@ -138,8 +188,10 @@ namespace osu.Game.Skinning
sw.WriteLine(line);
}
AddFile(item, stream, @"skin.ini");
AddFile(item, stream, @"skin.ini", realm);
}
item.Hash = ComputeHash(item);
}
}
@@ -154,36 +206,61 @@ namespace osu.Game.Skinning
return instance.Configuration.SkinInfo.Name == item.Name;
}
protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
{
var instance = createInstance(model);
model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo();
model.Name = instance.Configuration.SkinInfo.Name;
model.Creator = instance.Configuration.SkinInfo.Creator;
return Task.CompletedTask;
}
private void populateMissingHashes()
{
var skinsWithoutHashes = ModelStore.ConsumableItems.Where(i => i.Hash == null).ToArray();
foreach (SkinInfo skin in skinsWithoutHashes)
using (var realm = ContextFactory.CreateContext())
{
try
var skinsWithoutHashes = realm.All<SkinInfo>().Where(i => string.IsNullOrEmpty(i.Hash)).ToArray();
foreach (SkinInfo skin in skinsWithoutHashes)
{
Update(skin);
}
catch (Exception e)
{
Delete(skin);
Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid");
try
{
Update(skin);
}
catch (Exception e)
{
Delete(skin);
Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid");
}
}
}
}
private Skin createInstance(SkinInfo item) => item.CreateInstance(skinResources);
public void Save(Skin skin)
{
skin.SkinInfo.PerformWrite(s =>
{
// Serialise out the SkinInfo itself.
string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented });
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson)))
{
AddFile(s, streamContent, skin_info_file, s.Realm);
}
// Then serialise each of the drawable component groups into respective files.
foreach (var drawableInfo in skin.DrawableComponentInfo)
{
string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented });
using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json)))
{
string filename = @$"{drawableInfo.Key}.json";
var oldFile = s.Files.FirstOrDefault(f => f.Filename == filename);
if (oldFile != null)
ReplaceFile(oldFile, streamContent, s.Realm);
else
AddFile(s, streamContent, filename, s.Realm);
}
}
s.Hash = ComputeHash(s);
});
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ using osu.Game.Database;
namespace osu.Game.Skinning
{
public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes<SkinInfo, SkinFileInfo>
public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes<EFSkinInfo, SkinFileInfo>
{
public SkinStore(DatabaseContextFactory contextFactory, Storage storage = null)
: base(contextFactory, storage)
+196
View File
@@ -0,0 +1,196 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.IO.Archives;
using osu.Game.Models;
using osu.Game.Overlays.Notifications;
using Realms;
#nullable enable
namespace osu.Game.Stores
{
/// <summary>
/// Class which adds all the missing pieces bridging the gap between <see cref="RealmArchiveModelImporter{TModel}"/> and <see cref="ArchiveModelManager{TModel,TFileModel}"/>.
/// </summary>
public abstract class RealmArchiveModelManager<TModel> : RealmArchiveModelImporter<TModel>, IModelManager<TModel>, IModelFileManager<TModel, RealmNamedFileUsage>
where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete
{
public event Action<TModel>? ItemUpdated
{
// This may be brought back for beatmaps to ease integration.
// The eventual goal would be not requiring this and using realm subscriptions in its place.
add => throw new NotImplementedException();
remove => throw new NotImplementedException();
}
public event Action<TModel>? ItemRemoved
{
// This may be brought back for beatmaps to ease integration.
// The eventual goal would be not requiring this and using realm subscriptions in its place.
add => throw new NotImplementedException();
remove => throw new NotImplementedException();
}
private readonly RealmFileStore realmFileStore;
protected RealmArchiveModelManager(Storage storage, RealmContextFactory contextFactory)
: base(storage, contextFactory)
{
realmFileStore = new RealmFileStore(contextFactory, storage);
}
public void DeleteFile(TModel item, RealmNamedFileUsage file) =>
item.Realm.Write(() => DeleteFile(item, file, item.Realm));
public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents)
=> item.Realm.Write(() => ReplaceFile(file, contents, item.Realm));
public void AddFile(TModel item, Stream contents, string filename)
=> item.Realm.Write(() => AddFile(item, contents, filename, item.Realm));
/// <summary>
/// Delete a file from within an ongoing realm transaction.
/// </summary>
protected void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm)
{
item.Files.Remove(file);
}
/// <summary>
/// Replace a file from within an ongoing realm transaction.
/// </summary>
protected void ReplaceFile(RealmNamedFileUsage file, Stream contents, Realm realm)
{
file.File = realmFileStore.Add(contents, realm);
}
/// <summary>
/// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten.
/// </summary>
protected void AddFile(TModel item, Stream contents, string filename, Realm realm)
{
var existing = item.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
ReplaceFile(existing, contents, realm);
return;
}
var file = realmFileStore.Add(contents, realm);
var namedUsage = new RealmNamedFileUsage(file, filename);
item.Files.Add(namedUsage);
}
public override async Task<ILive<TModel>?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default)
{
return await base.Import(item, archive, lowPriority, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Delete multiple items.
/// This will post notifications tracking progress.
/// </summary>
public void Delete(List<TModel> items, bool silent = false)
{
if (items.Count == 0) return;
var notification = new ProgressNotification
{
Progress = 0,
Text = $"Preparing to delete all {HumanisedModelName}s...",
CompletionText = $"Deleted all {HumanisedModelName}s!",
State = ProgressNotificationState.Active,
};
if (!silent)
PostNotification?.Invoke(notification);
int i = 0;
foreach (var b in items)
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})";
Delete(b);
notification.Progress = (float)i / items.Count;
}
notification.State = ProgressNotificationState.Completed;
}
/// <summary>
/// Restore multiple items that were previously deleted.
/// This will post notifications tracking progress.
/// </summary>
public void Undelete(List<TModel> items, bool silent = false)
{
if (!items.Any()) return;
var notification = new ProgressNotification
{
CompletionText = "Restored all deleted items!",
Progress = 0,
State = ProgressNotificationState.Active,
};
if (!silent)
PostNotification?.Invoke(notification);
int i = 0;
foreach (var item in items)
{
if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort
return;
notification.Text = $"Restoring ({++i} of {items.Count})";
Undelete(item);
notification.Progress = (float)i / items.Count;
}
notification.State = ProgressNotificationState.Completed;
}
public bool Delete(TModel item)
{
if (item.DeletePending)
return false;
item.Realm.Write(r => item.DeletePending = true);
return true;
}
public void Undelete(TModel item)
{
if (!item.DeletePending)
return;
item.Realm.Write(r => item.DeletePending = false);
}
public virtual bool IsAvailableLocally(TModel model) => false; // Not relevant for skins since they can't be downloaded yet.
public void Update(TModel skin)
{
}
}
}
+13 -5
View File
@@ -18,7 +18,7 @@ using osu.Game.Rulesets;
namespace osu.Game.Stores
{
public class RealmRulesetStore : IDisposable
public class RealmRulesetStore : IRulesetStore, IDisposable
{
private readonly RealmContextFactory realmFactory;
@@ -29,9 +29,9 @@ namespace osu.Game.Stores
/// <summary>
/// All available rulesets.
/// </summary>
public IEnumerable<IRulesetInfo> AvailableRulesets => availableRulesets;
public IEnumerable<RealmRuleset> AvailableRulesets => availableRulesets;
private readonly List<IRulesetInfo> availableRulesets = new List<IRulesetInfo>();
private readonly List<RealmRuleset> availableRulesets = new List<RealmRuleset>();
public RealmRulesetStore(RealmContextFactory realmFactory, Storage? storage = null)
{
@@ -64,14 +64,14 @@ namespace osu.Game.Stores
/// </summary>
/// <param name="id">The ruleset's internal ID.</param>
/// <returns>A ruleset, if available, else null.</returns>
public IRulesetInfo? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id);
public RealmRuleset? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id);
/// <summary>
/// Retrieve a ruleset using a known short name.
/// </summary>
/// <param name="shortName">The ruleset's short name.</param>
/// <returns>A ruleset, if available, else null.</returns>
public IRulesetInfo? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName);
public RealmRuleset? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName);
private Assembly? resolveRulesetDependencyAssembly(object? sender, ResolveEventArgs args)
{
@@ -258,5 +258,13 @@ namespace osu.Game.Stores
{
AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly;
}
#region Implementation of IRulesetStore
IRulesetInfo? IRulesetStore.GetRuleset(int id) => GetRuleset(id);
IRulesetInfo? IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName);
IEnumerable<IRulesetInfo> IRulesetStore.AvailableRulesets => AvailableRulesets;
#endregion
}
}
@@ -15,6 +15,7 @@ using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO;
using osu.Game.Models;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects;
using osu.Game.Screens.Ranking;
@@ -88,11 +89,7 @@ namespace osu.Game.Tests.Beatmaps
AddStep("setup skins", () =>
{
userSkinInfo.Files.Clear();
userSkinInfo.Files.Add(new SkinFileInfo
{
Filename = userFile,
FileInfo = new IO.FileInfo { Hash = userFile }
});
userSkinInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = userFile }, userFile));
beatmapInfo.BeatmapSet.Files.Clear();
beatmapInfo.BeatmapSet.Files.Add(new BeatmapSetFileInfo
@@ -430,21 +430,19 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
Debug.Assert(Room != null);
// Add the item to the list first in order to compute gameplay order.
serverSidePlaylist.Add(item);
await updatePlaylistOrder(Room).ConfigureAwait(false);
item.ID = ++lastPlaylistItemId;
serverSidePlaylist.Add(item);
await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false);
await updatePlaylistOrder(Room).ConfigureAwait(false);
}
private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true)
{
MultiplayerPlaylistItem nextItem = serverSidePlaylist
.Where(i => !i.Expired)
.OrderBy(i => i.PlaylistOrder)
.FirstOrDefault()
?? room.Playlist.Last();
// Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item.
MultiplayerPlaylistItem nextItem = serverSidePlaylist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder).FirstOrDefault()
?? serverSidePlaylist.OrderByDescending(i => i.PlayedAt).First();
currentIndex = serverSidePlaylist.IndexOf(nextItem);
@@ -462,36 +460,32 @@ namespace osu.Game.Tests.Visual.Multiplayer
switch (room.Settings.QueueMode)
{
default:
orderedActiveItems = serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID == 0 ? int.MaxValue : item.ID).ToList();
orderedActiveItems = serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList();
break;
case QueueMode.AllPlayersRoundRobin:
orderedActiveItems = new List<MultiplayerPlaylistItem>();
var itemsByPriority = new List<(MultiplayerPlaylistItem item, int priority)>();
// Todo: This could probably be more efficient, likely at the cost of increased complexity.
// Number of "expired" or "used" items per player.
Dictionary<int, int> perUserCounts = serverSidePlaylist
.GroupBy(item => item.OwnerID)
.ToDictionary(group => group.Key, group => group.Count(item => item.Expired));
// We'll run a simulation over all items which are not expired ("unprocessed"). Expired items will not have their ordering updated.
List<MultiplayerPlaylistItem> unprocessedItems = serverSidePlaylist.Where(item => !item.Expired).ToList();
// In every iteration of the simulation, pick the first available item from the user with the lowest number of items in the queue to add to the result set.
// If multiple users have the same number of items in the queue, then the item with the lowest ID is chosen.
while (unprocessedItems.Count > 0)
// Assign a priority for items from each user, starting from 0 and increasing in order which the user added the items.
foreach (var group in room.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID))
{
MultiplayerPlaylistItem candidateItem = unprocessedItems
.OrderBy(item => perUserCounts[item.OwnerID])
.ThenBy(item => item.ID == 0 ? int.MaxValue : item.ID)
.First();
unprocessedItems.Remove(candidateItem);
orderedActiveItems.Add(candidateItem);
perUserCounts[candidateItem.OwnerID]++;
int priority = 0;
itemsByPriority.AddRange(group.Select(item => (item, priority++)));
}
orderedActiveItems = itemsByPriority
// Order by each user's priority.
.OrderBy(i => i.priority)
// Many users will have the same priority of items, so attempt to break the tie by maintaining previous ordering.
// Suppose there are two users: User1 and User2. User1 adds two items, and then User2 adds a third. If the previous order is not maintained,
// then after playing the first item by User1, their second item will become priority=0 and jump to the front of the queue (because it was added first).
.ThenBy(i => i.item.PlaylistOrder)
// If there are still ties (normally shouldn't happen), break ties by making items added earlier go first.
// This could happen if e.g. the item orders get reset.
.ThenBy(i => i.item.ID)
.Select(i => i.item)
.ToList();
break;
}
@@ -504,10 +498,6 @@ namespace osu.Game.Tests.Visual.Multiplayer
item.PlaylistOrder = (ushort)i;
// Items which have an ID of 0 are not in the database, so avoid propagating database/hub events for them.
if (item.ID <= 0)
continue;
await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false);
}
}
+1 -1
View File
@@ -95,7 +95,7 @@ namespace osu.Game.Tests.Visual
},
new OsuSpriteText
{
Text = skin?.SkinInfo?.Name ?? "none",
Text = skin?.SkinInfo?.Value.Name ?? "none",
Scale = new Vector2(1.5f),
Padding = new MarginPadding(5),
},
+5 -5
View File
@@ -29,9 +29,9 @@ namespace osu.Game.Users
{
public IBeatmapInfo BeatmapInfo { get; }
public RulesetInfo Ruleset { get; }
public IRulesetInfo Ruleset { get; }
protected InGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
protected InGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
{
BeatmapInfo = beatmapInfo;
Ruleset = ruleset;
@@ -42,7 +42,7 @@ namespace osu.Game.Users
public class InMultiplayerGame : InGame
{
public InMultiplayerGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
public InMultiplayerGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
: base(beatmapInfo, ruleset)
{
}
@@ -52,7 +52,7 @@ namespace osu.Game.Users
public class InPlaylistGame : InGame
{
public InPlaylistGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
public InPlaylistGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
: base(beatmapInfo, ruleset)
{
}
@@ -60,7 +60,7 @@ namespace osu.Game.Users
public class InSoloGame : InGame
{
public InSoloGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset)
public InSoloGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset)
: base(beatmapInfo, ruleset)
{
}
+1 -1
View File
@@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.7.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1203.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1207.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
<PackageReference Include="Sentry" Version="3.11.1" />
<PackageReference Include="SharpCompress" Version="0.30.0" />
+2 -2
View File
@@ -60,7 +60,7 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1203.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2021.1207.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1203.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net5.0 / net6.0) -->
@@ -83,7 +83,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1203.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1207.0" />
<PackageReference Include="SharpCompress" Version="0.30.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="SharpRaven" Version="2.4.0" />