1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-14 15:57:24 +08:00

Merge branch 'master' into results-clean

This commit is contained in:
Andrei Zavatski 2024-02-04 02:41:12 +03:00
commit 7b2adc857a
149 changed files with 1639 additions and 681 deletions

View File

@ -3,13 +3,13 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2022.2.3",
"version": "2023.3.3",
"commands": [
"jb"
]
},
"nvika": {
"version": "2.2.0",
"version": "3.0.0",
"commands": [
"nvika"
]

View File

@ -1,5 +1,3 @@
is_global = true
# .NET Code Style
# IDE styles reference: https://docs.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.127.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.131.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -20,5 +20,6 @@
<file src="**.dll" target="lib\net45\"/>
<file src="**.config" target="lib\net45\"/>
<file src="**.json" target="lib\net45\"/>
<file src="icon.png" target=""/>
</files>
</package>

View File

@ -91,7 +91,7 @@ namespace osu.Game.Rulesets.Mania.Tests
{
foreach (var stage in stages)
{
for (int i = 0; i < stage.Columns.Count; i++)
for (int i = 0; i < stage.Columns.Length; i++)
{
var obj = new Note { Column = i, StartTime = Time.Current + 2000 };
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@ -105,7 +105,7 @@ namespace osu.Game.Rulesets.Mania.Tests
{
foreach (var stage in stages)
{
for (int i = 0; i < stage.Columns.Count; i++)
for (int i = 0; i < stage.Columns.Length; i++)
{
var obj = new HoldNote { Column = i, StartTime = Time.Current + 2000, Duration = 500 };
obj.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());

View File

@ -17,7 +17,7 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Skinning.Argon
{
public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement
public partial class ArgonJudgementPiece : TextJudgementPiece, IAnimatableJudgement
{
private const float judgement_y_position = 160;

View File

@ -4,8 +4,6 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -28,20 +26,21 @@ namespace osu.Game.Rulesets.Mania.UI
/// <summary>
/// All contents added to this <see cref="ColumnFlow{TContent}"/>.
/// </summary>
public IReadOnlyList<TContent> Content => columns.Children.Select(c => c.Count == 0 ? null : (TContent)c.Child).ToList();
public TContent[] Content { get; }
private readonly FillFlowContainer<Container> columns;
private readonly FillFlowContainer<Container<TContent>> columns;
private readonly StageDefinition stageDefinition;
public ColumnFlow(StageDefinition stageDefinition)
{
this.stageDefinition = stageDefinition;
Content = new TContent[stageDefinition.Columns];
AutoSizeAxes = Axes.X;
Masking = true;
InternalChild = columns = new FillFlowContainer<Container>
InternalChild = columns = new FillFlowContainer<Container<TContent>>
{
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
@ -49,7 +48,7 @@ namespace osu.Game.Rulesets.Mania.UI
};
for (int i = 0; i < stageDefinition.Columns; i++)
columns.Add(new Container { RelativeSizeAxes = Axes.Y });
columns.Add(new Container<TContent> { RelativeSizeAxes = Axes.Y });
}
private ISkinSource currentSkin;
@ -102,7 +101,10 @@ namespace osu.Game.Rulesets.Mania.UI
/// </summary>
/// <param name="column">The index of the column to set the content of.</param>
/// <param name="content">The content.</param>
public void SetContentForColumn(int column, TContent content) => columns[column].Child = content;
public void SetContentForColumn(int column, TContent content)
{
Content[column] = columns[column].Child = content;
}
private void updateMobileSizing()
{

View File

@ -42,7 +42,16 @@ namespace osu.Game.Rulesets.Mania.UI
}
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => stages.Any(s => s.ReceivePositionalInputAt(screenSpacePos));
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{
foreach (var s in stages)
{
if (s.ReceivePositionalInputAt(screenSpacePos))
return true;
}
return false;
}
public ManiaPlayfield(List<StageDefinition> stageDefinitions)
{
@ -71,7 +80,7 @@ namespace osu.Game.Rulesets.Mania.UI
stages.Add(newStage);
AddNested(newStage);
firstColumnIndex += newStage.Columns.Count;
firstColumnIndex += newStage.Columns.Length;
}
}
@ -125,9 +134,9 @@ namespace osu.Game.Rulesets.Mania.UI
foreach (var stage in stages)
{
if (index >= stage.Columns.Count)
if (index >= stage.Columns.Length)
{
index -= stage.Columns.Count;
index -= stage.Columns.Length;
continue;
}
@ -140,7 +149,7 @@ namespace osu.Game.Rulesets.Mania.UI
/// <summary>
/// Retrieves the total amount of columns across all stages in this playfield.
/// </summary>
public int TotalColumns => stages.Sum(s => s.Columns.Count);
public int TotalColumns => stages.Sum(s => s.Columns.Length);
private Stage getStageByColumn(int column)
{
@ -148,7 +157,7 @@ namespace osu.Game.Rulesets.Mania.UI
foreach (var stage in stages)
{
sum += stage.Columns.Count;
sum += stage.Columns.Length;
if (sum > column)
return stage;
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
@ -37,7 +36,7 @@ namespace osu.Game.Rulesets.Mania.UI
public const float HIT_TARGET_POSITION = 110;
public IReadOnlyList<Column> Columns => columnFlow.Content;
public Column[] Columns => columnFlow.Content;
private readonly ColumnFlow<Column> columnFlow;
private readonly JudgementContainer<DrawableManiaJudgement> judgements;
@ -45,7 +44,16 @@ namespace osu.Game.Rulesets.Mania.UI
private readonly Drawable barLineContainer;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Columns.Any(c => c.ReceivePositionalInputAt(screenSpacePos));
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{
foreach (var c in Columns)
{
if (c.ReceivePositionalInputAt(screenSpacePos))
return true;
}
return false;
}
private readonly int firstColumnIndex;
@ -184,13 +192,13 @@ namespace osu.Game.Rulesets.Mania.UI
NewResult += OnNewResult;
}
public override void Add(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Add(hitObject);
public override void Add(HitObject hitObject) => Columns[((ManiaHitObject)hitObject).Column - firstColumnIndex].Add(hitObject);
public override bool Remove(HitObject hitObject) => Columns.ElementAt(((ManiaHitObject)hitObject).Column - firstColumnIndex).Remove(hitObject);
public override bool Remove(HitObject hitObject) => Columns[((ManiaHitObject)hitObject).Column - firstColumnIndex].Remove(hitObject);
public override void Add(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Add(h);
public override void Add(DrawableHitObject h) => Columns[((ManiaHitObject)h.HitObject).Column - firstColumnIndex].Add(h);
public override bool Remove(DrawableHitObject h) => Columns.ElementAt(((ManiaHitObject)h.HitObject).Column - firstColumnIndex).Remove(h);
public override bool Remove(DrawableHitObject h) => Columns[((ManiaHitObject)h.HitObject).Column - firstColumnIndex].Remove(h);
public void Add(BarLine barLine) => base.Add(barLine);

View File

@ -206,7 +206,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
AddStep($"click context menu item \"{contextMenuText}\"", () =>
{
MenuItem item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == "Curve type")?.Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
MenuItem item = visualiser.ContextMenuItems!.FirstOrDefault(menuItem => menuItem.Text.Value == "Curve type")?.Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
item?.Action.Value?.Invoke();
});

View File

@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new PathControlPoint(new Vector2(-128, 0), PathType.LINEAR) // absolute position: (0, 128)
}
},
RepeatCount = 1
RepeatCount = 2
};
slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
return slider;
@ -45,7 +45,9 @@ namespace osu.Game.Rulesets.Osu.Tests
OsuHitObjectGenerationUtils.ReflectHorizontallyAlongPlayfield(slider);
Assert.That(slider.Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().Single().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 0, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderHeadCircle>().Single().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().First().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X - 0, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderTailCircle>().Single().Position, Is.EqualTo(new Vector2(OsuPlayfield.BASE_SIZE.X, 128)));
Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[]
{
new Vector2(),
@ -62,7 +64,9 @@ namespace osu.Game.Rulesets.Osu.Tests
OsuHitObjectGenerationUtils.ReflectVerticallyAlongPlayfield(slider);
Assert.That(slider.Position, Is.EqualTo(new Vector2(128, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().Single().Position, Is.EqualTo(new Vector2(0, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderHeadCircle>().Single().Position, Is.EqualTo(new Vector2(128, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().First().Position, Is.EqualTo(new Vector2(0, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderTailCircle>().Single().Position, Is.EqualTo(new Vector2(0, OsuPlayfield.BASE_SIZE.Y - 128)));
Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[]
{
new Vector2(),
@ -79,7 +83,9 @@ namespace osu.Game.Rulesets.Osu.Tests
OsuHitObjectGenerationUtils.FlipSliderInPlaceHorizontally(slider);
Assert.That(slider.Position, Is.EqualTo(new Vector2(128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().Single().Position, Is.EqualTo(new Vector2(256, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderHeadCircle>().Single().Position, Is.EqualTo(new Vector2(128, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderRepeat>().First().Position, Is.EqualTo(new Vector2(256, 128)));
Assert.That(slider.NestedHitObjects.OfType<SliderTailCircle>().Single().Position, Is.EqualTo(new Vector2(256, 128)));
Assert.That(slider.Path.ControlPoints.Select(point => point.Position), Is.EquivalentTo(new[]
{
new Vector2(),

View File

@ -183,7 +183,7 @@ namespace osu.Game.Rulesets.Osu.Tests
break;
}
hitObjectContainer.Add(drawableObject);
hitObjectContainer.Add(drawableObject!);
followPointRenderer.AddFollowPoints(objects[i]);
}
});

View File

@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@ -173,6 +174,7 @@ namespace osu.Game.Rulesets.Osu.Tests
public IEnumerable<ISkin> AllSources => new[] { this };
[CanBeNull]
public event Action SourceChanged;
private bool enabled = true;

View File

@ -239,11 +239,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
if (Tracking.Value && Time.Current >= HitObject.StartTime)
{
// keep the sliding sample playing at the current tracking position
if (!slidingSample.IsPlaying)
if (!slidingSample.RequestedPlaying)
slidingSample.Play();
slidingSample.Balance.Value = CalculateSamplePlaybackBalance(CalculateDrawableRelativePosition(Ball));
}
else if (slidingSample.IsPlaying)
else if (slidingSample.IsPlaying || slidingSample.RequestedPlaying)
slidingSample.Stop();
}
}

View File

@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public const double ANIM_DURATION = 150;
private const float default_tick_size = 16;
public const float DEFAULT_TICK_SIZE = 16;
protected DrawableSlider DrawableSlider => (DrawableSlider)ParentHitObject;
@ -44,8 +44,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
Masking = true,
Origin = Anchor.Centre,
Size = new Vector2(default_tick_size),
BorderThickness = default_tick_size / 4,
Size = new Vector2(DEFAULT_TICK_SIZE),
BorderThickness = DEFAULT_TICK_SIZE / 4,
BorderColour = Color4.White,
Child = new Box
{
@ -88,8 +88,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
break;
case ArmedState.Miss:
this.FadeOut(ANIM_DURATION);
this.TransformBindableTo(AccentColour, Color4.Red, 0);
this.FadeOut(ANIM_DURATION, Easing.OutQuint);
break;
case ArmedState.Hit:

View File

@ -153,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
if (tracking.NewValue)
{
if (!spinningSample.IsPlaying)
if (!spinningSample.RequestedPlaying)
spinningSample.Play();
spinningSample.VolumeTo(1, 300);

View File

@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu.Objects
set
{
repeatCount = value;
endPositionCache.Invalidate();
updateNestedPositions();
}
}
@ -165,7 +165,7 @@ namespace osu.Game.Rulesets.Osu.Objects
public Slider()
{
SamplesBindable.CollectionChanged += (_, _) => UpdateNestedSamples();
Path.Version.ValueChanged += _ => endPositionCache.Invalidate();
Path.Version.ValueChanged += _ => updateNestedPositions();
}
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)

View File

@ -98,7 +98,7 @@ namespace osu.Game.Rulesets.Osu
base.ReloadMappings(realmKeyBindings);
if (!AllowGameplayInputs)
KeyBindings = KeyBindings.Where(b => b.GetAction<OsuAction>() == OsuAction.Smoke).ToList();
KeyBindings = KeyBindings.Where(static b => b.GetAction<OsuAction>() == OsuAction.Smoke).ToList();
}
}
}

View File

@ -28,6 +28,7 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Osu.Skinning.Argon;
using osu.Game.Rulesets.Osu.Skinning.Default;
using osu.Game.Rulesets.Osu.Skinning.Legacy;
using osu.Game.Rulesets.Osu.Statistics;
using osu.Game.Rulesets.Osu.UI;
@ -254,6 +255,9 @@ namespace osu.Game.Rulesets.Osu
case ArgonSkin:
return new OsuArgonSkinTransformer(skin);
case TrianglesSkin:
return new OsuTrianglesSkinTransformer(skin);
}
return null;

View File

@ -16,7 +16,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement
public partial class ArgonJudgementPiece : TextJudgementPiece, IAnimatableJudgement
{
private RingExplosion? ringExplosion;

View File

@ -0,0 +1,51 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
public partial class ArgonJudgementPieceSliderTickMiss : CompositeDrawable, IAnimatableJudgement
{
private readonly HitResult result;
private Circle piece = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public ArgonJudgementPieceSliderTickMiss(HitResult result)
{
this.result = result;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(piece = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Blending = BlendingParameters.Additive,
Colour = colours.ForHitResult(result),
Size = new Vector2(ArgonSliderScorePoint.SIZE)
});
}
public void PlayAnimation()
{
this.ScaleTo(1.4f);
this.ScaleTo(1f, 150, Easing.Out);
this.FadeOutFromOne(600);
}
public Drawable? GetAboveHitObjectsProxiedContent() => piece.CreateProxy();
}
}

View File

@ -16,14 +16,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
private Bindable<Color4> accentColour = null!;
private const float size = 12;
public const float SIZE = 12;
[BackgroundDependencyLoader]
private void load(DrawableHitObject hitObject)
{
Masking = true;
Origin = Anchor.Centre;
Size = new Vector2(size);
Size = new Vector2(SIZE);
BorderThickness = 3;
BorderColour = Color4.White;
Child = new Box

View File

@ -19,11 +19,21 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
switch (lookup)
{
case GameplaySkinComponentLookup<HitResult> resultComponent:
HitResult result = resultComponent.Component;
// This should eventually be moved to a skin setting, when supported.
if (Skin is ArgonProSkin && (resultComponent.Component == HitResult.Great || resultComponent.Component == HitResult.Perfect))
if (Skin is ArgonProSkin && (result == HitResult.Great || result == HitResult.Perfect))
return Drawable.Empty();
return new ArgonJudgementPiece(resultComponent.Component);
switch (result)
{
case HitResult.IgnoreMiss:
case HitResult.LargeTickMiss:
return new ArgonJudgementPieceSliderTickMiss(result);
default:
return new ArgonJudgementPiece(result);
}
case OsuSkinComponentLookup osuComponent:
// TODO: Once everything is finalised, consider throwing UnsupportedSkinComponentException on missing entries.

View File

@ -0,0 +1,52 @@
// 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.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public partial class DefaultJudgementPieceSliderTickMiss : CompositeDrawable, IAnimatableJudgement
{
private readonly HitResult result;
private Circle piece = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public DefaultJudgementPieceSliderTickMiss(HitResult result)
{
this.result = result;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(piece = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Blending = BlendingParameters.Additive,
Colour = colours.ForHitResult(result),
Size = new Vector2(DrawableSliderTick.DEFAULT_TICK_SIZE)
});
}
public void PlayAnimation()
{
this.ScaleTo(1.4f);
this.ScaleTo(1f, 150, Easing.Out);
this.FadeOutFromOne(600);
}
public Drawable? GetAboveHitObjectsProxiedContent() => piece.CreateProxy();
}
}

View File

@ -0,0 +1,38 @@
// 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.Game.Rulesets.Scoring;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public class OsuTrianglesSkinTransformer : SkinTransformer
{
public OsuTrianglesSkinTransformer(ISkin skin)
: base(skin)
{
}
public override Drawable? GetDrawableComponent(ISkinComponentLookup lookup)
{
switch (lookup)
{
case GameplaySkinComponentLookup<HitResult> resultComponent:
HitResult result = resultComponent.Component;
switch (result)
{
case HitResult.IgnoreMiss:
case HitResult.LargeTickMiss:
// use argon judgement piece for new tick misses because i don't want to design another one for triangles.
return new DefaultJudgementPieceSliderTickMiss(result);
}
break;
}
return base.GetDrawableComponent(lookup);
}
}
}

View File

@ -17,7 +17,7 @@ using osuTK.Graphics;
namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{
public partial class ArgonJudgementPiece : JudgementPiece, IAnimatableJudgement
public partial class ArgonJudgementPiece : TextJudgementPiece, IAnimatableJudgement
{
private RingExplosion? ringExplosion;

View File

@ -16,7 +16,6 @@ using osu.Game.Replays;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Replays;
using osu.Game.Rulesets.Timing;
@ -36,6 +35,8 @@ namespace osu.Game.Rulesets.Taiko.UI
public new TaikoInputManager KeyBindingInputManager => (TaikoInputManager)base.KeyBindingInputManager;
protected new TaikoPlayfieldAdjustmentContainer PlayfieldAdjustmentContainer => (TaikoPlayfieldAdjustmentContainer)base.PlayfieldAdjustmentContainer;
protected override bool UserScrollSpeedAdjustment => false;
private SkinnableDrawable scroller;
@ -68,22 +69,7 @@ namespace osu.Game.Rulesets.Taiko.UI
TimeRange.Value = ComputeTimeRange();
}
protected virtual double ComputeTimeRange()
{
// Taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened.
const float scroll_rate = 10;
// Since the time range will depend on a positional value, it is referenced to the x480 pixel space.
// Width is used because it defines how many notes fit on the playfield.
// We clamp the ratio to the maximum aspect ratio to keep scroll speed consistent on widths lower than the default.
float ratio = Math.Max(DrawSize.X / 768f, TaikoPlayfieldAdjustmentContainer.MAXIMUM_ASPECT);
// Stable internally increased the slider velocity of objects by a factor of `VELOCITY_MULTIPLIER`.
// To simulate this, we shrink the time range by that factor here.
// This, when combined with the rest of the scrolling ruleset machinery (see `MultiplierControlPoint` et al.),
// has the effect of increasing each multiplier control point's multiplier by `VELOCITY_MULTIPLIER`, ensuring parity with stable.
return (Playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate / TaikoBeatmapConverter.VELOCITY_MULTIPLIER;
}
protected virtual double ComputeTimeRange() => PlayfieldAdjustmentContainer.ComputeTimeRange();
protected override void UpdateAfterChildren()
{

View File

@ -179,10 +179,9 @@ namespace osu.Game.Rulesets.Taiko.UI
TaikoAction taikoAction = getTaikoActionFromPosition(position);
// Not too sure how this can happen, but let's avoid throwing.
if (trackedActions.ContainsKey(source))
if (!trackedActions.TryAdd(source, taikoAction))
return;
trackedActions.Add(source, taikoAction);
keyBindingContainer.TriggerPressed(taikoAction);
}

View File

@ -4,6 +4,7 @@
using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Taiko.Beatmaps;
using osu.Game.Rulesets.UI;
using osuTK;
@ -14,6 +15,8 @@ namespace osu.Game.Rulesets.Taiko.UI
public const float MAXIMUM_ASPECT = 16f / 9f;
public const float MINIMUM_ASPECT = 5f / 4f;
private const float stable_gamefield_height = 480f;
public readonly IBindable<bool> LockPlayfieldAspectRange = new BindableBool(true);
public TaikoPlayfieldAdjustmentContainer()
@ -21,6 +24,9 @@ namespace osu.Game.Rulesets.Taiko.UI
RelativeSizeAxes = Axes.X;
RelativePositionAxes = Axes.Y;
Height = TaikoPlayfield.BASE_HEIGHT;
// Matches stable, see https://github.com/peppy/osu-stable-reference/blob/7519cafd1823f1879c0d9c991ba0e5c7fd3bfa02/osu!/GameModes/Play/Rulesets/Taiko/RulesetTaiko.cs#L514
Y = 135f / stable_gamefield_height;
}
protected override void Update()
@ -28,8 +34,6 @@ namespace osu.Game.Rulesets.Taiko.UI
base.Update();
const float base_relative_height = TaikoPlayfield.BASE_HEIGHT / 768;
// Matches stable, see https://github.com/peppy/osu-stable-reference/blob/7519cafd1823f1879c0d9c991ba0e5c7fd3bfa02/osu!/GameModes/Play/Rulesets/Taiko/RulesetTaiko.cs#L514
const float base_position = 135f / 480f;
float relativeHeight = base_relative_height;
@ -51,10 +55,38 @@ namespace osu.Game.Rulesets.Taiko.UI
// Limit the maximum relative height of the playfield to one-third of available area to avoid it masking out on extreme resolutions.
relativeHeight = Math.Min(relativeHeight, 1f / 3f);
Y = base_position;
Scale = new Vector2(Math.Max((Parent!.ChildSize.Y / 768f) * (relativeHeight / base_relative_height), 1f));
Width = 1 / Scale.X;
}
public double ComputeTimeRange()
{
float currentAspect = Parent!.ChildSize.X / Parent!.ChildSize.Y;
if (LockPlayfieldAspectRange.Value)
currentAspect = Math.Clamp(currentAspect, MINIMUM_ASPECT, MAXIMUM_ASPECT);
// in a game resolution of 1024x768, stable's scrolling system consists of objects being placed 600px (widthScaled - 40) away from their hit location.
// however, the point at which the object renders at the end of the screen is exactly x=640, but stable makes the object start moving from beyond the screen instead of the boundary point.
// therefore, in lazer we have to adjust the "in length" so that it's in a 640px->160px fashion before passing it down as a "time range".
// see stable's "in length": https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManagerTaiko.cs#L168
const float stable_hit_location = 160f;
float widthScaled = currentAspect * stable_gamefield_height;
float inLength = widthScaled - stable_hit_location;
// also in a game resolution of 1024x768, stable makes hit objects scroll from 760px->160px at a duration of 6000ms, divided by slider velocity (i.e. at a rate of 0.1px/ms)
// compare: https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManagerTaiko.cs#L218
// note: the variable "sv", in the linked reference, is equivalent to MultiplierControlPoint.Multiplier * 100, but since time range is agnostic of velocity, we replace "sv" with 100 below.
float inMsLength = inLength / 100 * 1000;
// stable multiplies the slider velocity by 1.4x for certain reasons, divide the time range by that factor to achieve similar result.
// for references on how the factor is applied to the time range, see:
// 1. https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManagerTaiko.cs#L79 (DifficultySliderMultiplier multiplied by 1.4x)
// 2. https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManager.cs#L468-L470 (DifficultySliderMultiplier used to calculate SliderScoringPointDistance)
// 3. https://github.com/peppy/osu-stable-reference/blob/013c3010a9d495e3471a9c59518de17006f9ad89/osu!/GameplayElements/HitObjectManager.cs#L248-L250 (SliderScoringPointDistance used to calculate slider velocity, i.e. the "sv" variable from above)
inMsLength /= TaikoBeatmapConverter.VELOCITY_MULTIPLIER;
return inMsLength;
}
}
}

View File

@ -7,6 +7,7 @@ using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Moq;
using NUnit.Framework;
using osu.Game.Beatmaps;
@ -98,9 +99,10 @@ namespace osu.Game.Tests.Beatmaps
Beatmap = beatmap;
}
#pragma warning disable CS0067
[CanBeNull]
public event Action<HitObject, IEnumerable<HitObject>> ObjectConverted;
protected virtual void OnObjectConverted(HitObject arg1, IEnumerable<HitObject> arg2) => ObjectConverted?.Invoke(arg1, arg2);
#pragma warning restore CS0067
public IBeatmap Beatmap { get; }

View File

@ -75,8 +75,6 @@ namespace osu.Game.Tests.Chat
return false;
};
});
AddUntilStep("wait for notifications client", () => channelManager.NotificationsConnected);
}
[Test]

View File

@ -79,7 +79,7 @@ namespace osu.Game.Tests.Database
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore
var _ = new RealmRulesetStore(realm, storage);
_ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
});
@ -104,13 +104,13 @@ namespace osu.Game.Tests.Database
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore
var _ = new RealmRulesetStore(realm, storage);
_ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
// Simulate the ruleset getting updated
LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION;
var __ = new RealmRulesetStore(realm, storage);
_ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
});

View File

@ -203,9 +203,9 @@ namespace osu.Game.Tests.Gameplay
public IRenderer Renderer => host.Renderer;
public AudioManager AudioManager => Audio;
public IResourceStore<byte[]> Files => null;
public IResourceStore<byte[]> Files => null!;
public new IResourceStore<byte[]> Resources => base.Resources;
public RealmAccess RealmAccess => null;
public RealmAccess RealmAccess => null!;
public IResourceStore<TextureUpload> CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => null;
#endregion

View File

@ -169,9 +169,9 @@ namespace osu.Game.Tests.NonVisual.Skinning
public IRenderer Renderer => new DummyRenderer();
public AudioManager AudioManager => null;
public IResourceStore<byte[]> Files => null;
public IResourceStore<byte[]> Resources => null;
public RealmAccess RealmAccess => null;
public IResourceStore<byte[]> Files => null!;
public IResourceStore<byte[]> Resources => null!;
public RealmAccess RealmAccess => null!;
public IResourceStore<TextureUpload> CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => textureStore;
}
}

View File

@ -56,9 +56,9 @@ namespace osu.Game.Tests.Rulesets
public override IEnumerable<Mod> GetModsFor(ModType type) => new Mod[] { null };
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => null;
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null;
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null;
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => null!;
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null!;
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null!;
}
private class TestAPIIncompatibleRuleset : Ruleset
@ -69,9 +69,9 @@ namespace osu.Game.Tests.Rulesets
// simulate API incompatibility by throwing similar exceptions.
public override IEnumerable<Mod> GetModsFor(ModType type) => throw new MissingMethodException();
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => null;
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null;
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null;
public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList<Mod> mods = null) => null!;
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null!;
public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null!;
}
}
}

View File

@ -142,7 +142,7 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("dismiss prompt", () =>
{
var button = DialogOverlay.CurrentDialog.Buttons.Last();
var button = DialogOverlay.CurrentDialog!.Buttons.Last();
InputManager.MoveMouseTo(button);
InputManager.Click(MouseButton.Left);
});
@ -167,7 +167,7 @@ namespace osu.Game.Tests.Visual.Editing
});
AddUntilStep("save prompt shown", () => DialogOverlay.CurrentDialog is SaveRequiredPopupDialog);
AddStep("save changes", () => DialogOverlay.CurrentDialog.PerformOkAction());
AddStep("save changes", () => DialogOverlay.CurrentDialog!.PerformOkAction());
EditorPlayer editorPlayer = null;
AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null);

View File

@ -47,8 +47,8 @@ namespace osu.Game.Tests.Visual.Gameplay
seekTo(referenceBeatmap.HitObjects[^1].GetEndTime());
AddUntilStep("results displayed", () => getResultsScreen()?.IsLoaded == true);
AddAssert("score has combo", () => getResultsScreen().Score.Combo > 100);
AddAssert("score has no misses", () => getResultsScreen().Score.Statistics[HitResult.Miss] == 0);
AddAssert("score has combo", () => getResultsScreen().Score!.Combo > 100);
AddAssert("score has no misses", () => getResultsScreen().Score!.Statistics[HitResult.Miss] == 0);
AddUntilStep("avatar displayed", () => getAvatar() != null);
AddAssert("avatar not clickable", () => getAvatar().ChildrenOfType<OsuClickableContainer>().First().Action == null);

View File

@ -138,8 +138,8 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen);
// Player creates new instance of mods after gameplay to ensure any runtime references to drawables etc. are not retained.
AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.Not.SameAs(playerMods.First()));
AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(playerMods.First()));
AddAssert("results screen score has copied mods", () => (Player.GetChildScreen() as ResultsScreen)?.Score?.Mods.First(), () => Is.Not.SameAs(playerMods.First()));
AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score?.Mods.First(), () => Is.EqualTo(playerMods.First()));
AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null));
AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID))!.Mods.First(), () => Is.EqualTo(playerMods.First()));
@ -184,7 +184,11 @@ namespace osu.Game.Tests.Visual.Gameplay
CreateTest();
AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning);
AddStep("log back in", () => API.Login("username", "password"));
AddStep("log back in", () =>
{
API.Login("username", "password");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime()));

View File

@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.Gameplay
protected override void Update()
{
base.Update();
playbackManager?.ReplayInputHandler.SetFrameFromTime(Time.Current - 100);
playbackManager?.ReplayInputHandler?.SetFrameFromTime(Time.Current - 100);
}
[TearDownSteps]

View File

@ -198,7 +198,7 @@ namespace osu.Game.Tests.Visual.Gameplay
foreach (var legacyFrame in frames.Frames)
{
var frame = new TestReplayFrame();
frame.FromLegacy(legacyFrame, null);
frame.FromLegacy(legacyFrame, null!);
playbackReplay.Frames.Add(frame);
}

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using System.Net;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -9,7 +10,9 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays;
using osu.Game.Overlays.Login;
using osu.Game.Users.Drawables;
using osuTK.Input;
@ -18,6 +21,8 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture]
public partial class TestSceneLoginOverlay : OsuManualInputManagerTestScene
{
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private LoginOverlay loginOverlay = null!;
[BackgroundDependencyLoader]
@ -40,9 +45,69 @@ namespace osu.Game.Tests.Visual.Menus
public void TestLoginSuccess()
{
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "88800088")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "88800088");
assertAPIState(APIState.Online);
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
}
private void assertAPIState(APIState expected) =>
AddUntilStep($"login state is {expected}", () => API.State.Value, () => Is.EqualTo(expected));
[Test]
public void TestVerificationFailure()
{
bool verificationHandled = false;
AddStep("reset flag", () => verificationHandled = false);
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "88800088")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
verificationHandled = true;
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "abcdefgh");
AddUntilStep("wait for verification handled", () => verificationHandled);
assertAPIState(APIState.RequiresSecondFactorAuth);
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
}
[Test]
@ -78,6 +143,12 @@ namespace osu.Game.Tests.Visual.Menus
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "88800088");
assertAPIState(APIState.Online);
AddStep("click on flag", () =>
{
InputManager.MoveMouseTo(loginOverlay.ChildrenOfType<UpdateableFlag>().First());

View File

@ -250,7 +250,7 @@ namespace osu.Game.Tests.Visual.Menus
{
}
public virtual IBindable<int> UnreadCount => null;
public virtual IBindable<int> UnreadCount { get; } = new Bindable<int>();
public IEnumerable<Notification> AllNotifications => Enumerable.Empty<Notification>();
}

View File

@ -16,6 +16,8 @@ namespace osu.Game.Tests.Visual.Menus
[TestFixture]
public partial class TestSceneToolbarUserButton : OsuManualInputManagerTestScene
{
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
public TestSceneToolbarUserButton()
{
Container mainContainer;
@ -69,18 +71,20 @@ namespace osu.Game.Tests.Visual.Menus
[Test]
public void TestLoginLogout()
{
AddStep("Log out", () => ((DummyAPIAccess)API).Logout());
AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang"));
AddStep("Log out", () => dummyAPI.Logout());
AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh"));
}
[Test]
public void TestStates()
{
AddStep("Log in", () => ((DummyAPIAccess)API).Login("wang", "jang"));
AddStep("Log in", () => dummyAPI.Login("wang", "jang"));
AddStep("Authenticate via second factor", () => dummyAPI.AuthenticateSecondFactor("abcdefgh"));
foreach (var state in Enum.GetValues<APIState>())
{
AddStep($"Change state to {state}", () => ((DummyAPIAccess)API).SetState(state));
AddStep($"Change state to {state}", () => dummyAPI.SetState(state));
}
}
}

View File

@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
.SkipWhile(r => r.Room.Category.Value == RoomCategory.Spotlight)
.All(r => r.Room.Category.Value == RoomCategory.Normal));
AddStep("remove first room", () => RoomManager.RemoveRoom(RoomManager.Rooms.FirstOrDefault(r => r.RoomID.Value == 0)));
AddStep("remove first room", () => RoomManager.RemoveRoom(RoomManager.Rooms.First(r => r.RoomID.Value == 0)));
AddAssert("has 4 rooms", () => container.Rooms.Count == 4);
AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0));

View File

@ -698,7 +698,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
var scoreInfo = ((ResultsScreen)multiplayerComponents.CurrentScreen).Score;
return !scoreInfo.Passed && scoreInfo.Rank == ScoreRank.F;
return scoreInfo?.Passed == false && scoreInfo.Rank == ScoreRank.F;
});
}

View File

@ -3,13 +3,18 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.Play;
@ -19,14 +24,37 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
private MultiplayerPlayer player;
[SetUpSteps]
public override void SetUpSteps()
[Test]
public void TestGameplay()
{
base.SetUpSteps();
setup();
AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value);
}
[Test]
public void TestFail()
{
setup(() => new[] { new OsuModAutopilot() });
AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value);
AddStep("set health zero", () => player.ChildrenOfType<HealthProcessor>().Single().Health.Value = 0);
AddUntilStep("wait for fail", () => player.ChildrenOfType<HealthProcessor>().Single().HasFailed);
AddAssert("fail animation not shown", () => !player.GameplayState.HasFailed);
// ensure that even after reaching a failed state, score processor keeps accounting for new hit results.
// the testing method used here (autopilot + hold key) is sort-of dodgy, but works enough.
AddAssert("score is zero", () => player.GameplayState.ScoreProcessor.TotalScore.Value == 0);
AddStep("hold key", () => player.ChildrenOfType<OsuInputManager.RulesetKeyBindingContainer>().First().TriggerPressed(OsuAction.LeftButton));
AddUntilStep("score changed", () => player.GameplayState.ScoreProcessor.TotalScore.Value > 0);
}
private void setup(Func<IReadOnlyList<Mod>> mods = null)
{
AddStep("set beatmap", () =>
{
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
SelectedMods.Value = mods?.Invoke() ?? Array.Empty<Mod>();
});
AddStep("Start track playing", () =>
@ -52,11 +80,5 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("gameplay clock is not paused", () => !player.ChildrenOfType<GameplayClockContainer>().Single().IsPaused.Value);
AddAssert("gameplay clock is running", () => player.ChildrenOfType<GameplayClockContainer>().Single().IsRunning);
}
[Test]
public void TestGameplay()
{
AddUntilStep("wait for gameplay start", () => player.LocalUserPlaying.Value);
}
}
}

View File

@ -193,7 +193,7 @@ namespace osu.Game.Tests.Visual.Navigation
case ScorePresentType.Results:
AddUntilStep("wait for results", () => lastWaitedScreen != Game.ScreenStack.CurrentScreen && Game.ScreenStack.CurrentScreen is ResultsScreen);
AddStep("store last waited screen", () => lastWaitedScreen = Game.ScreenStack.CurrentScreen);
AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score.Equals(getImport()));
AddUntilStep("correct score displayed", () => ((ResultsScreen)Game.ScreenStack.CurrentScreen).Score!.Equals(getImport()));
AddAssert("correct ruleset selected", () => Game.Ruleset.Value.Equals(getImport().Ruleset));
break;

View File

@ -10,6 +10,8 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.AccountCreation;
@ -59,7 +61,40 @@ namespace osu.Game.Tests.Visual.Online
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().SingleOrDefault()?.IsPresent == true);
AddStep("log back in", () => API.Login("dummy", "password"));
AddStep("log back in", () =>
{
API.Login("dummy", "password");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden);
}
[Test]
public void TestFullFlow()
{
AddStep("log out", () => API.Logout());
AddStep("show manually", () => accountCreation.Show());
AddUntilStep("overlay is visible", () => accountCreation.State.Value == Visibility.Visible);
AddStep("click button", () => accountCreation.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddUntilStep("warning screen is present", () => accountCreation.ChildrenOfType<ScreenWarning>().SingleOrDefault()?.IsPresent == true);
AddStep("proceed", () => accountCreation.ChildrenOfType<DangerousSettingsButton>().Single().TriggerClick());
AddUntilStep("entry screen is present", () => accountCreation.ChildrenOfType<ScreenEntry>().SingleOrDefault()?.IsPresent == true);
AddStep("input details", () =>
{
var entryScreen = accountCreation.ChildrenOfType<ScreenEntry>().Single();
entryScreen.ChildrenOfType<OsuTextBox>().ElementAt(0).Text = "new_user";
entryScreen.ChildrenOfType<OsuTextBox>().ElementAt(1).Text = "new.user@fake.mail";
entryScreen.ChildrenOfType<OsuTextBox>().ElementAt(2).Text = "password";
});
AddStep("click button", () => accountCreation.ChildrenOfType<ScreenEntry>().Single()
.ChildrenOfType<SettingsButton>().Single().TriggerClick());
AddUntilStep("verification screen is present", () => accountCreation.ChildrenOfType<ScreenEmailVerification>().SingleOrDefault()?.IsPresent == true);
AddStep("verify", () => ((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh"));
AddUntilStep("overlay is hidden", () => accountCreation.State.Value == Visibility.Hidden);
}
}

View File

@ -67,6 +67,7 @@ namespace osu.Game.Tests.Visual.Online
Schedule(() =>
{
API.Login("test", "test");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
Child = commentsContainer = new CommentsContainer();
});
}

View File

@ -6,6 +6,7 @@
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays.BeatmapSet.Buttons;
using osuTK;
@ -34,14 +35,22 @@ namespace osu.Game.Tests.Visual.Online
AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 });
AddStep("log out", () => API.Logout());
checkEnabled(false);
AddStep("log in", () => API.Login("test", "test"));
AddStep("log in", () =>
{
API.Login("test", "test");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
checkEnabled(true);
}
[Test]
public void TestBeatmapChange()
{
AddStep("log in", () => API.Login("test", "test"));
AddStep("log in", () =>
{
API.Login("test", "test");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
});
AddStep("set valid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet { OnlineID = 88 });
checkEnabled(true);
AddStep("set invalid beatmap", () => favourite.BeatmapSet.Value = new APIBeatmapSet());

View File

@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.Online
else
{
int userId = int.Parse(getUserRequest.Lookup);
string rulesetName = getUserRequest.Ruleset.ShortName;
string rulesetName = getUserRequest.Ruleset!.ShortName;
var response = new APIUser
{
Id = userId,
@ -177,7 +177,11 @@ namespace osu.Game.Tests.Visual.Online
AddWaitStep("wait a bit", 5);
AddAssert("update not received", () => update == null);
AddStep("log in user", () => dummyAPI.Login("user", "password"));
AddStep("log in user", () =>
{
dummyAPI.Login("user", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
}
[Test]

View File

@ -52,7 +52,11 @@ namespace osu.Game.Tests.Visual.Online
AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 }));
AddToggleStep("toggle visibility", visible => profile.State.Value = visible ? Visibility.Visible : Visibility.Hidden);
AddStep("log out", () => dummyAPI.Logout());
AddStep("log back in", () => dummyAPI.Login("username", "password"));
AddStep("log back in", () =>
{
dummyAPI.Login("username", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
}
[Test]
@ -98,7 +102,11 @@ namespace osu.Game.Tests.Visual.Online
});
AddStep("logout", () => dummyAPI.Logout());
AddStep("show user", () => profile.ShowUser(new APIUser { Id = 1 }));
AddStep("login", () => dummyAPI.Login("username", "password"));
AddStep("login", () =>
{
dummyAPI.Login("username", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
AddWaitStep("wait some", 3);
AddStep("complete request", () => pendingRequest.TriggerSuccess(TEST_USER));
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Allocation;
using osu.Game.Overlays;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.API;
namespace osu.Game.Tests.Visual.Online
{
@ -72,7 +73,11 @@ namespace osu.Game.Tests.Visual.Online
AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible);
}
private void logIn() => API.Login("localUser", "password");
private void logIn()
{
API.Login("localUser", "password");
((DummyAPIAccess)API).AuthenticateSecondFactor("abcdefgh");
}
private Comment getUserComment() => new Comment
{

View File

@ -179,7 +179,7 @@ namespace osu.Game.Tests.Visual.Playlists
public IBindable<bool> InitialRoomsReceived { get; } = new Bindable<bool>(true);
public IBindableList<Room> Rooms => null;
public IBindableList<Room> Rooms => null!;
public void AddOrUpdateRoom(Room room) => throw new NotImplementedException();

View File

@ -420,7 +420,7 @@ namespace osu.Game.Tests.Visual.Playlists
public new LoadingSpinner RightSpinner => base.RightSpinner;
public new ScorePanelList ScorePanelList => base.ScorePanelList;
public TestResultsScreen(ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true)
public TestResultsScreen([CanBeNull] ScoreInfo score, int roomId, PlaylistItem playlistItem, bool allowRetry = true)
: base(score, roomId, playlistItem, allowRetry)
{
}

View File

@ -52,11 +52,11 @@ namespace osu.Game.Tests.Visual.Playlists
[SetUpSteps]
public void SetupSteps()
{
AddStep("set room", () => SelectedRoom.Value = new Room());
AddStep("set room", () => SelectedRoom!.Value = new Room());
importBeatmap();
AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom.Value)));
AddStep("load match", () => LoadScreen(match = new TestPlaylistsRoomSubScreen(SelectedRoom!.Value)));
AddUntilStep("wait for load", () => match.IsCurrentScreen());
}
@ -115,7 +115,7 @@ namespace osu.Game.Tests.Visual.Playlists
});
});
AddAssert("first playlist item selected", () => match.SelectedItem.Value == SelectedRoom.Value.Playlist[0]);
AddAssert("first playlist item selected", () => match.SelectedItem.Value == SelectedRoom!.Value.Playlist[0]);
}
[Test]
@ -201,7 +201,7 @@ namespace osu.Game.Tests.Visual.Playlists
private void setupAndCreateRoom(Action<Room> room)
{
AddStep("setup room", () => room(SelectedRoom.Value));
AddStep("setup room", () => room(SelectedRoom!.Value));
AddStep("click create button", () =>
{

View File

@ -418,7 +418,7 @@ namespace osu.Game.Tests.Visual.Ranking
public UnrankedSoloResultsScreen(ScoreInfo score)
: base(score, true)
{
Score.BeatmapInfo!.OnlineID = 0;
Score!.BeatmapInfo!.OnlineID = 0;
Score.BeatmapInfo.Status = BeatmapOnlineStatus.Pending;
}
@ -432,7 +432,7 @@ namespace osu.Game.Tests.Visual.Ranking
private class RulesetWithNoPerformanceCalculator : OsuRuleset
{
public override PerformanceCalculator CreatePerformanceCalculator() => null;
public override PerformanceCalculator CreatePerformanceCalculator() => null!;
}
}
}

View File

@ -125,7 +125,11 @@ namespace osu.Game.Tests.Visual.UserInterface
assertLoggedOutState();
// moving from logged out -> logged in
AddStep("log back in", () => dummyAPI.Login("username", "password"));
AddStep("log back in", () =>
{
dummyAPI.Login("username", "password");
dummyAPI.AuthenticateSecondFactor("abcdefgh");
});
assertLoggedInState();
}

View File

@ -213,7 +213,7 @@ namespace osu.Game.Tests.Visual.UserInterface
{
}
public virtual IBindable<int> UnreadCount => null;
public virtual IBindable<int> UnreadCount { get; } = new Bindable<int>();
public IEnumerable<Notification> AllNotifications => Enumerable.Empty<Notification>();
}

View File

@ -21,7 +21,6 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary>
public readonly BindableDouble ScrollSpeedBindable = new BindableDouble(1)
{
Precision = 0.01,
MinValue = 0.01,
MaxValue = 10
};

View File

@ -567,10 +567,9 @@ namespace osu.Game.Beatmaps.Formats
for (int i = pendingControlPoints.Count - 1; i >= 0; i--)
{
var type = pendingControlPoints[i].GetType();
if (pendingControlPointTypes.Contains(type))
if (!pendingControlPointTypes.Add(type))
continue;
pendingControlPointTypes.Add(type);
beatmap.ControlPointInfo.Add(pendingControlPointsTime, pendingControlPoints[i]);
}

View File

@ -116,7 +116,7 @@ namespace osu.Game.Beatmaps
ITrackStore IBeatmapResourceProvider.Tracks => trackStore;
IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer();
AudioManager IStorageResourceProvider.AudioManager => audioManager;
RealmAccess IStorageResourceProvider.RealmAccess => null;
RealmAccess IStorageResourceProvider.RealmAccess => null!;
IResourceStore<byte[]> IStorageResourceProvider.Files => files;
IResourceStore<byte[]> IStorageResourceProvider.Resources => resources;
IResourceStore<TextureUpload> IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore);

View File

@ -52,10 +52,10 @@ namespace osu.Game.Graphics.Containers
public override void Add(T drawable)
{
base.Add(drawable);
Debug.Assert(drawable != null);
base.Add(drawable);
drawable.StateChanged += state => selectionChanged(drawable, state);
}

View File

@ -77,7 +77,7 @@ namespace osu.Game.Graphics
{
case HitResult.IgnoreMiss:
case HitResult.SmallTickMiss:
return Orange1;
return Color4.Gray;
case HitResult.Miss:
case HitResult.LargeTickMiss:

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Graphics.Sprites;
namespace osu.Game.Graphics.UserInterface
@ -48,6 +49,7 @@ namespace osu.Game.Graphics.UserInterface
{
protected virtual float ChevronSize => 10;
[CanBeNull]
public event Action<Visibility> StateChanged;
public readonly SpriteIcon Chevron;

View File

@ -41,6 +41,6 @@ namespace osu.Game.IO
/// </summary>
/// <param name="underlyingStore">The underlying provider of texture data (in arbitrary image formats).</param>
/// <returns>A texture loader store.</returns>
IResourceStore<TextureUpload> CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore);
IResourceStore<TextureUpload>? CreateTextureLoaderStore(IResourceStore<byte[]> underlyingStore);
}
}

View File

@ -19,6 +19,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString Connecting => new TranslatableString(getKey(@"connecting"), @"Connecting...");
/// <summary>
/// "Verification required"
/// </summary>
public static LocalisableString VerificationRequired => new TranslatableString(getKey(@"verification_required"), @"Verification required");
/// <summary>
/// "home"
/// </summary>

View File

@ -21,7 +21,7 @@ using osu.Game.Configuration;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Users;
@ -48,6 +48,8 @@ namespace osu.Game.Online.API
public string ProvidedUsername { get; private set; }
public string SecondFactorCode { get; private set; }
private string password;
public IBindable<APIUser> LocalUser => localUser;
@ -55,6 +57,8 @@ namespace osu.Game.Online.API
public IBindable<UserActivity> Activity => activity;
public IBindable<UserStatistics> Statistics => statistics;
public INotificationsClient NotificationsClient { get; }
public Language Language => game.CurrentLanguage.Value;
private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser());
@ -82,6 +86,7 @@ namespace osu.Game.Online.API
APIEndpointUrl = endpointConfiguration.APIEndpointUrl;
WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl;
NotificationsClient = setUpNotificationsClient();
authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl);
log = Logger.GetLogger(LoggingTarget.Network);
@ -114,6 +119,30 @@ namespace osu.Game.Online.API
thread.Start();
}
private WebSocketNotificationsClientConnector setUpNotificationsClient()
{
var connector = new WebSocketNotificationsClientConnector(this);
connector.MessageReceived += msg =>
{
switch (msg.Event)
{
case @"verified":
if (state.Value == APIState.RequiresSecondFactorAuth)
state.Value = APIState.Online;
break;
case @"logout":
if (state.Value == APIState.Online)
Logout();
break;
}
};
return connector;
}
private void onTokenChanged(ValueChangedEvent<OAuthToken> e) => config.SetValue(OsuSetting.Token, config.Get<bool>(OsuSetting.SavePassword) ? authentication.TokenString : string.Empty);
internal new void Schedule(Action action) => base.Schedule(action);
@ -197,6 +226,7 @@ namespace osu.Game.Online.API
/// </summary>
/// <remarks>
/// This method takes control of <see cref="state"/> and transitions from <see cref="APIState.Connecting"/> to either
/// - <see cref="APIState.RequiresSecondFactorAuth"/> (pending 2fa)
/// - <see cref="APIState.Online"/> (successful connection)
/// - <see cref="APIState.Failing"/> (failed connection but retrying)
/// - <see cref="APIState.Offline"/> (failed and can't retry, clear credentials and require user interaction)
@ -204,8 +234,6 @@ namespace osu.Game.Online.API
/// <returns>Whether the connection attempt was successful.</returns>
private void attemptConnect()
{
state.Value = APIState.Connecting;
if (localUser.IsDefault)
{
// Show a placeholder user if saved credentials are available.
@ -223,6 +251,7 @@ namespace osu.Game.Online.API
if (!authentication.HasValidAccessToken)
{
state.Value = APIState.Connecting;
LastLoginError = null;
try
@ -240,40 +269,79 @@ namespace osu.Game.Online.API
}
}
var userReq = new GetUserRequest();
userReq.Failure += ex =>
switch (state.Value)
{
if (ex is APIException)
case APIState.RequiresSecondFactorAuth:
{
LastLoginError = ex;
log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!");
Logout();
if (string.IsNullOrEmpty(SecondFactorCode))
return;
state.Value = APIState.Connecting;
LastLoginError = null;
var verificationRequest = new VerifySessionRequest(SecondFactorCode);
verificationRequest.Success += () => state.Value = APIState.Online;
verificationRequest.Failure += ex =>
{
state.Value = APIState.RequiresSecondFactorAuth;
LastLoginError = ex;
SecondFactorCode = null;
};
if (!handleRequest(verificationRequest))
{
state.Value = APIState.Failing;
return;
}
if (state.Value != APIState.Online)
return;
break;
}
else if (ex is WebException webException && webException.Message == @"Unauthorized")
default:
{
log.Add(@"Login no longer valid");
Logout();
var userReq = new GetMeRequest();
userReq.Failure += ex =>
{
if (ex is APIException)
{
LastLoginError = ex;
log.Add($@"Login failed for username {ProvidedUsername} on user retrieval ({LastLoginError.Message})!");
Logout();
}
else if (ex is WebException webException && webException.Message == @"Unauthorized")
{
log.Add(@"Login no longer valid");
Logout();
}
else
{
state.Value = APIState.Failing;
}
};
userReq.Success += me =>
{
me.Status.Value = configStatus.Value ?? UserStatus.Online;
setLocalUser(me);
state.Value = me.SessionVerified ? APIState.Online : APIState.RequiresSecondFactorAuth;
failureCount = 0;
};
if (!handleRequest(userReq))
{
state.Value = APIState.Failing;
return;
}
break;
}
else
{
state.Value = APIState.Failing;
}
};
userReq.Success += user =>
{
user.Status.Value = configStatus.Value ?? UserStatus.Online;
setLocalUser(user);
// we're connected!
state.Value = APIState.Online;
failureCount = 0;
};
if (!handleRequest(userReq))
{
state.Value = APIState.Failing;
return;
}
var friendsReq = new GetFriendsRequest();
@ -321,11 +389,17 @@ namespace osu.Game.Online.API
this.password = password;
}
public void AuthenticateSecondFactor(string code)
{
Debug.Assert(State.Value == APIState.RequiresSecondFactorAuth);
SecondFactorCode = code;
}
public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) =>
new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack);
public NotificationsClientConnector GetNotificationsConnector() =>
new WebSocketNotificationsClientConnector(this);
public IChatClient GetChatClient() => new WebSocketChatClient(this);
public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password)
{
@ -507,6 +581,7 @@ namespace osu.Game.Online.API
public void Logout()
{
password = null;
SecondFactorCode = null;
authentication.Clear();
// Scheduled prior to state change such that the state changed event is invoked with the correct user and their friends present
@ -566,6 +641,11 @@ namespace osu.Game.Online.API
/// </summary>
Failing,
/// <summary>
/// Waiting on second factor authentication.
/// </summary>
RequiresSecondFactorAuth,
/// <summary>
/// We are in the process of (re-)connecting.
/// </summary>

View File

@ -7,8 +7,10 @@ using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Tests;
using osu.Game.Users;
@ -30,6 +32,9 @@ namespace osu.Game.Online.API
public Bindable<UserStatistics?> Statistics { get; } = new Bindable<UserStatistics?>();
public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient();
INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient;
public Language Language => Language.en;
public string AccessToken => "token";
@ -57,6 +62,7 @@ namespace osu.Game.Online.API
private bool shouldFailNextLogin;
private bool stayConnectingNextLogin;
private bool requiredSecondFactorAuth = true;
/// <summary>
/// The current connectivity state of the API.
@ -117,13 +123,46 @@ namespace osu.Game.Online.API
Id = DUMMY_USER_ID,
};
if (requiredSecondFactorAuth)
{
state.Value = APIState.RequiresSecondFactorAuth;
}
else
{
onSuccessfulLogin();
requiredSecondFactorAuth = true;
}
}
public void AuthenticateSecondFactor(string code)
{
var request = new VerifySessionRequest(code);
request.Failure += e =>
{
state.Value = APIState.RequiresSecondFactorAuth;
LastLoginError = e;
};
state.Value = APIState.Connecting;
LastLoginError = null;
// if no handler installed / handler can't handle verification, just assume that the server would verify for simplicity.
if (HandleRequest?.Invoke(request) != true)
onSuccessfulLogin();
// if a handler did handle this, make sure the verification actually passed.
if (request.CompletionState == APIRequestCompletionState.Completed)
onSuccessfulLogin();
}
private void onSuccessfulLogin()
{
state.Value = APIState.Online;
Statistics.Value = new UserStatistics
{
GlobalRank = 1,
CountryRank = 1
};
state.Value = APIState.Online;
}
public void Logout()
@ -144,7 +183,7 @@ namespace osu.Game.Online.API
public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null;
public NotificationsClientConnector GetNotificationsConnector() => new PollingNotificationsClientConnector(this);
public IChatClient GetChatClient() => new TestChatClientConnector(this);
public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password)
{
@ -159,6 +198,11 @@ namespace osu.Game.Online.API
IBindable<UserActivity> IAPIProvider.Activity => Activity;
IBindable<UserStatistics?> IAPIProvider.Statistics => Statistics;
/// <summary>
/// Skip 2FA requirement for next login.
/// </summary>
public void SkipSecondFactor() => requiredSecondFactorAuth = false;
/// <summary>
/// During the next simulated login, the process will fail immediately.
/// </summary>

View File

@ -6,7 +6,8 @@ using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Users;
namespace osu.Game.Online.API
@ -111,6 +112,12 @@ namespace osu.Game.Online.API
/// <param name="password">The user's password.</param>
void Login(string username, string password);
/// <summary>
/// Provide a second-factor authentication code for authentication.
/// </summary>
/// <param name="code">The 2FA code.</param>
void AuthenticateSecondFactor(string code);
/// <summary>
/// Log out the current user.
/// </summary>
@ -130,9 +137,14 @@ namespace osu.Game.Online.API
IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true);
/// <summary>
/// Constructs a new <see cref="NotificationsClientConnector"/>.
/// Accesses the <see cref="INotificationsClient"/> used to receive asynchronous notifications from web.
/// </summary>
NotificationsClientConnector GetNotificationsConnector();
INotificationsClient NotificationsClient { get; }
/// <summary>
/// Creates a <see cref="IChatClient"/> instance to use in order to chat.
/// </summary>
IChatClient GetChatClient();
/// <summary>
/// Create a new user account. This is a blocking operation.

View File

@ -128,19 +128,12 @@ namespace osu.Game.Online.API
// if we already have a valid access token, let's use it.
if (accessTokenValid) return true;
// we want to ensure only a single authentication update is happening at once.
lock (access_token_retrieval_lock)
{
// re-check if valid, in case another request completed and revalidated our access.
if (accessTokenValid) return true;
// if not, let's try using our refresh token to request a new access token.
if (!string.IsNullOrEmpty(Token.Value?.RefreshToken))
// ReSharper disable once PossibleNullReferenceException
AuthenticateWithRefresh(Token.Value.RefreshToken);
// if not, let's try using our refresh token to request a new access token.
if (!string.IsNullOrEmpty(Token.Value?.RefreshToken))
// ReSharper disable once PossibleNullReferenceException
AuthenticateWithRefresh(Token.Value.RefreshToken);
return accessTokenValid;
}
return accessTokenValid;
}
private bool accessTokenValid => Token.Value?.IsValid ?? false;
@ -149,14 +142,18 @@ namespace osu.Game.Online.API
internal string RequestAccessToken()
{
if (!ensureAccessToken()) return null;
lock (access_token_retrieval_lock)
{
if (!ensureAccessToken()) return null;
return Token.Value.AccessToken;
return Token.Value.AccessToken;
}
}
internal void Clear()
{
Token.Value = null;
lock (access_token_retrieval_lock)
Token.Value = null;
}
private class AccessTokenRequestRefresh : AccessTokenRequest

View File

@ -0,0 +1,24 @@
// 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.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets;
namespace osu.Game.Online.API.Requests
{
public class GetMeRequest : APIRequest<APIMe>
{
public readonly IRulesetInfo? Ruleset;
/// <summary>
/// Gets the currently logged-in user.
/// </summary>
/// <param name="ruleset">The ruleset to get the user's info for.</param>
public GetMeRequest(IRulesetInfo? ruleset = null)
{
Ruleset = ruleset;
}
protected override string Target => $@"me/{Ruleset?.ShortName}";
}
}

View File

@ -1,7 +1,6 @@
// 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.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
@ -9,7 +8,7 @@ namespace osu.Game.Online.API.Requests
public class GetSystemTitleRequest : OsuJsonWebRequest<APISystemTitle>
{
public GetSystemTitleRequest()
: base($@"https://assets.ppy.sh/lazer-status.json?{DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 1800}")
: base(@"https://assets.ppy.sh/lazer-status.json")
{
}
}

View File

@ -1,8 +1,6 @@
// 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.
#nullable disable
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets;
@ -11,24 +9,17 @@ namespace osu.Game.Online.API.Requests
public class GetUserRequest : APIRequest<APIUser>
{
public readonly string Lookup;
public readonly IRulesetInfo Ruleset;
public readonly IRulesetInfo? Ruleset;
private readonly LookupType lookupType;
/// <summary>
/// Gets the currently logged-in user.
/// </summary>
public GetUserRequest()
{
}
/// <summary>
/// Gets a user from their ID.
/// </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, IRulesetInfo ruleset = null)
public GetUserRequest(long? userId = null, IRulesetInfo? ruleset = null)
{
Lookup = userId.ToString();
Lookup = userId.ToString()!;
lookupType = LookupType.Id;
Ruleset = ruleset;
}
@ -38,14 +29,14 @@ 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, IRulesetInfo ruleset = null)
public GetUserRequest(string username, IRulesetInfo? ruleset = null)
{
Lookup = username;
lookupType = LookupType.Username;
Ruleset = ruleset;
}
protected override string Target => Lookup != null ? $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}" : $@"me/{Ruleset?.ShortName}";
protected override string Target => $@"users/{Lookup}/{Ruleset?.ShortName}?key={lookupType.ToString().ToLowerInvariant()}";
private enum LookupType
{

View File

@ -0,0 +1,22 @@
// 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.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class ReissueVerificationCodeRequest : APIRequest
{
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
return req;
}
protected override string Target => @"session/verify/reissue";
}
}

View File

@ -0,0 +1,13 @@
// 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 Newtonsoft.Json;
namespace osu.Game.Online.API.Requests.Responses
{
public class APIMe : APIUser
{
[JsonProperty("session_verified")]
public bool SessionVerified { get; set; }
}
}

View File

@ -0,0 +1,30 @@
// 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.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class VerifySessionRequest : APIRequest
{
public readonly string VerificationKey;
public VerifySessionRequest(string verificationKey)
{
VerificationKey = verificationKey;
}
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
req.AddParameter(@"verification_key", VerificationKey);
return req;
}
protected override string Target => @"session/verify";
}
}

View File

@ -16,7 +16,6 @@ using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Overlays.Chat.Listing;
namespace osu.Game.Online.Chat
@ -64,13 +63,8 @@ namespace osu.Game.Online.Chat
/// </summary>
public IBindableList<Channel> AvailableChannels => availableChannels;
/// <summary>
/// Whether the client responsible for channel notifications is connected.
/// </summary>
public bool NotificationsConnected => connector.IsConnected.Value;
private readonly IAPIProvider api;
private readonly NotificationsClientConnector connector;
private readonly IChatClient chatClient;
[Resolved]
private UserLookupCache users { get; set; }
@ -85,7 +79,7 @@ namespace osu.Game.Online.Chat
{
this.api = api;
connector = api.GetNotificationsConnector();
chatClient = api.GetChatClient();
CurrentChannel.ValueChanged += currentChannelChanged;
}
@ -93,15 +87,11 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader]
private void load()
{
connector.ChannelJoined += ch => Schedule(() => joinChannel(ch));
connector.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false));
connector.NewMessages += msgs => Schedule(() => addMessages(msgs));
connector.PresenceReceived += () => Schedule(initializeChannels);
connector.Start();
chatClient.ChannelJoined += ch => Schedule(() => joinChannel(ch));
chatClient.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false));
chatClient.NewMessages += msgs => Schedule(() => addMessages(msgs));
chatClient.PresenceReceived += () => Schedule(initializeChannels);
chatClient.RequestPresence();
apiState.BindTo(api.State);
apiState.BindValueChanged(_ => SendAck(), true);
@ -655,7 +645,7 @@ namespace osu.Game.Online.Chat
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
connector?.Dispose();
chatClient?.Dispose();
}
}

View File

@ -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;
using System.Collections.Generic;
namespace osu.Game.Online.Chat
{
/// <summary>
/// Interface for consuming online chat.
/// </summary>
public interface IChatClient : IDisposable
{
/// <summary>
/// Fired when a <see cref="Channel"/> has been joined.
/// </summary>
event Action<Channel>? ChannelJoined;
/// <summary>
/// Fired when a <see cref="Channel"/> has been parted.
/// </summary>
event Action<Channel>? ChannelParted;
/// <summary>
/// Fired when new <see cref="Message"/>s have arrived from the server.
/// </summary>
event Action<List<Message>>? NewMessages;
/// <summary>
/// Requests presence information from the server.
/// </summary>
void RequestPresence();
/// <summary>
/// Fired when the initial user presence information has been received.
/// </summary>
event Action? PresenceReceived;
}
}

View File

@ -0,0 +1,173 @@
// 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.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Notifications.WebSocket;
namespace osu.Game.Online.Chat
{
public class WebSocketChatClient : IChatClient
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
private readonly IAPIProvider api;
private readonly INotificationsClient client;
private readonly ConcurrentDictionary<long, Channel> channelsMap = new ConcurrentDictionary<long, Channel>();
private CancellationTokenSource? chatStartCancellationSource;
public WebSocketChatClient(IAPIProvider api)
{
this.api = api;
client = api.NotificationsClient;
client.IsConnected.BindValueChanged(onConnectedChanged, true);
}
private void onConnectedChanged(ValueChangedEvent<bool> connected)
{
if (connected.NewValue)
{
client.MessageReceived += onMessageReceived;
attemptToStartChat();
RequestPresence();
}
else
chatStartCancellationSource?.Cancel();
}
private void attemptToStartChat()
{
chatStartCancellationSource?.Cancel();
chatStartCancellationSource = new CancellationTokenSource();
Task.Factory.StartNew(async () =>
{
while (!chatStartCancellationSource.IsCancellationRequested)
{
try
{
await client.SendAsync(new StartChatRequest()).ConfigureAwait(false);
Logger.Log(@"Now listening to websocket chat messages.", LoggingTarget.Network);
chatStartCancellationSource.Cancel();
}
catch (Exception ex)
{
Logger.Log($@"Could not start listening to websocket chat messages: {ex}", LoggingTarget.Network);
await Task.Delay(5000).ConfigureAwait(false);
}
}
}, chatStartCancellationSource.Token);
}
public void RequestPresence()
{
var fetchReq = new GetUpdatesRequest(0);
fetchReq.Success += updates =>
{
if (updates?.Presence != null)
{
foreach (var channel in updates.Presence)
joinChannel(channel);
handleMessages(updates.Messages);
}
PresenceReceived?.Invoke();
};
api.Queue(fetchReq);
}
private void onMessageReceived(SocketMessage message)
{
switch (message.Event)
{
case @"chat.channel.join":
Debug.Assert(message.Data != null);
Channel? joinedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(joinedChannel != null);
joinChannel(joinedChannel);
break;
case @"chat.channel.part":
Debug.Assert(message.Data != null);
Channel? partedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(partedChannel != null);
partChannel(partedChannel);
break;
case @"chat.message.new":
Debug.Assert(message.Data != null);
NewChatMessageData? messageData = JsonConvert.DeserializeObject<NewChatMessageData>(message.Data.ToString());
Debug.Assert(messageData != null);
foreach (var msg in messageData.Messages)
postToChannel(msg);
break;
}
}
private void postToChannel(Message message)
{
if (channelsMap.TryGetValue(message.ChannelId, out Channel? channel))
{
joinChannel(channel);
NewMessages?.Invoke(new List<Message> { message });
return;
}
var req = new GetChannelRequest(message.ChannelId);
req.Success += response =>
{
joinChannel(channelsMap[message.ChannelId] = response.Channel);
NewMessages?.Invoke(new List<Message> { message });
};
req.Failure += ex => Logger.Error(ex, "Failed to join channel");
api.Queue(req);
}
private void joinChannel(Channel ch)
{
ch.Joined.Value = true;
ChannelJoined?.Invoke(ch);
}
private void partChannel(Channel channel) => ChannelParted?.Invoke(channel);
private void handleMessages(List<Message>? messages)
{
if (messages == null)
return;
NewMessages?.Invoke(messages);
}
public void Dispose()
{
client.IsConnected.ValueChanged -= onConnectedChanged;
client.MessageReceived -= onMessageReceived;
}
}
}

View File

@ -1,8 +1,6 @@
// 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.
#nullable disable
namespace osu.Game.Online
{
/// <summary>
@ -13,36 +11,36 @@ namespace osu.Game.Online
/// <summary>
/// The base URL for the website.
/// </summary>
public string WebsiteRootUrl { get; set; }
public string WebsiteRootUrl { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the main (osu-web) API.
/// </summary>
public string APIEndpointUrl { get; set; }
public string APIEndpointUrl { get; set; } = string.Empty;
/// <summary>
/// The OAuth client secret.
/// </summary>
public string APIClientSecret { get; set; }
public string APIClientSecret { get; set; } = string.Empty;
/// <summary>
/// The OAuth client ID.
/// </summary>
public string APIClientID { get; set; }
public string APIClientID { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the SignalR spectator server.
/// </summary>
public string SpectatorEndpointUrl { get; set; }
public string SpectatorEndpointUrl { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the SignalR multiplayer server.
/// </summary>
public string MultiplayerEndpointUrl { get; set; }
public string MultiplayerEndpointUrl { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the SignalR metadata server.
/// </summary>
public string MetadataEndpointUrl { get; set; }
public string MetadataEndpointUrl { get; set; } = string.Empty;
}
}

View File

@ -1,19 +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.
namespace osu.Game.Online
{
public class ExperimentalEndpointConfiguration : EndpointConfiguration
{
public ExperimentalEndpointConfiguration()
{
WebsiteRootUrl = @"https://osu.ppy.sh";
APIEndpointUrl = @"https://lazer.ppy.sh";
APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk";
APIClientID = "5";
SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator";
MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer";
MetadataEndpointUrl = "https://spectator.ppy.sh/metadata";
}
}
}

View File

@ -1,42 +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 System.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Chat;
namespace osu.Game.Online.Notifications
{
/// <summary>
/// An abstract connector or <see cref="NotificationsClient"/>s.
/// </summary>
public abstract class NotificationsClientConnector : PersistentEndpointClientConnector
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
protected NotificationsClientConnector(IAPIProvider api)
: base(api)
{
}
protected sealed override async Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{
var client = await BuildNotificationClientAsync(cancellationToken).ConfigureAwait(false);
client.ChannelJoined = c => ChannelJoined?.Invoke(c);
client.ChannelParted = c => ChannelParted?.Invoke(c);
client.NewMessages = m => NewMessages?.Invoke(m);
client.PresenceReceived = () => PresenceReceived?.Invoke();
return client;
}
protected abstract Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,29 @@
// 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.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
namespace osu.Game.Online.Notifications.WebSocket
{
public class DummyNotificationsClient : INotificationsClient
{
public IBindable<bool> IsConnected => new BindableBool(true);
public event Action<SocketMessage>? MessageReceived;
public Func<SocketMessage, bool>? HandleMessage;
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (HandleMessage?.Invoke(message) != true)
throw new InvalidOperationException($@"{nameof(DummyNotificationsClient)} cannot process this message.");
return Task.CompletedTask;
}
public void Receive(SocketMessage message) => MessageReceived?.Invoke(message);
}
}

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;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
namespace osu.Game.Online.Notifications.WebSocket
{
/// <summary>
/// A client for asynchronous notifications sent by osu-web.
/// </summary>
public interface INotificationsClient
{
/// <summary>
/// Whether this <see cref="INotificationsClient"/> is currently connected to a server.
/// </summary>
IBindable<bool> IsConnected { get; }
/// <summary>
/// Invoked when a new <see cref="SocketMessage"/> arrives for this client.
/// </summary>
event Action<SocketMessage>? MessageReceived;
/// <summary>
/// Sends a <see cref="SocketMessage"/> to the notification server.
/// </summary>
Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default);
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net;
using System.Net.WebSockets;
@ -12,23 +11,20 @@ using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Logging;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Chat;
namespace osu.Game.Online.Notifications.WebSocket
{
/// <summary>
/// A notifications client which receives events via a websocket.
/// </summary>
public class WebSocketNotificationsClient : NotificationsClient
public class WebSocketNotificationsClient : PersistentEndpointClient
{
public event Action<SocketMessage>? MessageReceived;
private readonly ClientWebSocket socket;
private readonly string endpoint;
private readonly ConcurrentDictionary<long, Channel> channelsMap = new ConcurrentDictionary<long, Channel>();
public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint, IAPIProvider api)
: base(api)
public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint)
{
this.socket = socket;
this.endpoint = endpoint;
@ -37,11 +33,7 @@ namespace osu.Game.Online.Notifications.WebSocket
public override async Task ConnectAsync(CancellationToken cancellationToken)
{
await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false);
await sendMessage(new StartChatRequest(), CancellationToken.None).ConfigureAwait(false);
runReadLoop(cancellationToken);
await base.ConnectAsync(cancellationToken).ConfigureAwait(false);
}
private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () =>
@ -73,7 +65,7 @@ namespace osu.Game.Online.Notifications.WebSocket
break;
}
await onMessageReceivedAsync(message).ConfigureAwait(false);
MessageReceived?.Invoke(message);
}
break;
@ -105,69 +97,12 @@ namespace osu.Game.Online.Notifications.WebSocket
}
}
private async Task sendMessage(SocketMessage message, CancellationToken cancellationToken)
public async Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (socket.State != WebSocketState.Open)
return;
await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken).ConfigureAwait(false);
}
private async Task onMessageReceivedAsync(SocketMessage message)
{
switch (message.Event)
{
case @"chat.channel.join":
Debug.Assert(message.Data != null);
Channel? joinedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(joinedChannel != null);
HandleChannelJoined(joinedChannel);
break;
case @"chat.channel.part":
Debug.Assert(message.Data != null);
Channel? partedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(partedChannel != null);
HandleChannelParted(partedChannel);
break;
case @"chat.message.new":
Debug.Assert(message.Data != null);
NewChatMessageData? messageData = JsonConvert.DeserializeObject<NewChatMessageData>(message.Data.ToString());
Debug.Assert(messageData != null);
foreach (var msg in messageData.Messages)
HandleChannelJoined(await getChannel(msg.ChannelId).ConfigureAwait(false));
HandleMessages(messageData.Messages);
break;
}
}
private async Task<Channel> getChannel(long channelId)
{
if (channelsMap.TryGetValue(channelId, out Channel? channel))
return channel;
var tsc = new TaskCompletionSource<Channel>();
var req = new GetChannelRequest(channelId);
req.Success += response =>
{
channelsMap[channelId] = response.Channel;
tsc.SetResult(response.Channel);
};
req.Failure += ex => tsc.SetException(ex);
API.Queue(req);
return await tsc.Task.ConfigureAwait(false);
await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken ?? CancellationToken.None).ConfigureAwait(false);
}
public override async ValueTask DisposeAsync()

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 System.Net;
using System.Net.WebSockets;
using System.Threading;
@ -13,26 +14,26 @@ namespace osu.Game.Online.Notifications.WebSocket
/// <summary>
/// A connector for <see cref="WebSocketNotificationsClient"/>s that receive events via a websocket.
/// </summary>
public class WebSocketNotificationsClientConnector : NotificationsClientConnector
public class WebSocketNotificationsClientConnector : PersistentEndpointClientConnector, INotificationsClient
{
public event Action<SocketMessage>? MessageReceived;
private readonly IAPIProvider api;
public WebSocketNotificationsClientConnector(IAPIProvider api)
: base(api)
{
this.api = api;
Start();
}
protected override async Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken)
protected override async Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<string>();
var req = new GetNotificationsRequest();
req.Success += bundle => tcs.SetResult(bundle.Endpoint);
req.Failure += ex => tcs.SetException(ex);
api.Queue(req);
string endpoint = await tcs.Task.ConfigureAwait(false);
// must use `PerformAsync()`, since we may not be fully online yet
// (see `APIState.RequiresSecondFactorAuth` - in this state queued requests will not execute).
await api.PerformAsync(req).ConfigureAwait(false);
string endpoint = req.Response!.Endpoint;
ClientWebSocket socket = new ClientWebSocket();
socket.Options.SetRequestHeader(@"Authorization", @$"Bearer {api.AccessToken}");
@ -40,7 +41,17 @@ namespace osu.Game.Online.Notifications.WebSocket
if (socket.Options.Proxy != null)
socket.Options.Proxy.Credentials = CredentialCache.DefaultCredentials;
return new WebSocketNotificationsClient(socket, endpoint, api);
var client = new WebSocketNotificationsClient(socket, endpoint);
client.MessageReceived += msg => MessageReceived?.Invoke(msg);
return client;
}
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (CurrentConnection is not WebSocketNotificationsClient webSocketClient)
return Task.CompletedTask;
return webSocketClient.SendAsync(message, cancellationToken);
}
}
}

View File

@ -11,6 +11,7 @@ using osu.Framework.Screens;
using osu.Game.Online.API;
using osu.Game.Online.Metadata;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Online.Spectator;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
@ -25,6 +26,8 @@ namespace osu.Game.Online
{
private readonly Func<IScreen> getCurrentScreen;
private INotificationsClient notificationsClient = null!;
[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;
@ -55,9 +58,11 @@ namespace osu.Game.Online
private void load(IAPIProvider api)
{
apiState = api.State.GetBoundCopy();
notificationsClient = api.NotificationsClient;
multiplayerState = multiplayerClient.IsConnected.GetBoundCopy();
spectatorState = spectatorClient.IsConnected.GetBoundCopy();
notificationsClient.MessageReceived += notifyAboutForcedDisconnection;
multiplayerClient.Disconnecting += notifyAboutForcedDisconnection;
spectatorClient.Disconnecting += notifyAboutForcedDisconnection;
metadataClient.Disconnecting += notifyAboutForcedDisconnection;
@ -127,10 +132,27 @@ namespace osu.Game.Online
});
}
private void notifyAboutForcedDisconnection(SocketMessage obj)
{
if (obj.Event != @"logout") return;
if (userNotified) return;
userNotified = true;
notificationOverlay?.Post(new SimpleErrorNotification
{
Icon = FontAwesome.Solid.ExclamationCircle,
Text = "You have been logged out due to a change to your account. Please log in again."
});
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (notificationsClient.IsNotNull())
notificationsClient.MessageReceived += notifyAboutForcedDisconnection;
if (spectatorClient.IsNotNull())
spectatorClient.Disconnecting -= notifyAboutForcedDisconnection;

View File

@ -3,6 +3,7 @@
#nullable disable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -79,10 +80,14 @@ namespace osu.Game.Online
case APIState.Failing:
case APIState.Connecting:
case APIState.RequiresSecondFactorAuth:
PopContentOut(Content);
LoadingSpinner.Show();
placeholder.FadeOut(transform_duration / 2, Easing.OutQuint);
break;
default:
throw new ArgumentOutOfRangeException();
}
});

View File

@ -69,6 +69,7 @@ namespace osu.Game.Online
break;
case APIState.Online:
case APIState.RequiresSecondFactorAuth:
await connect().ConfigureAwait(true);
break;
}
@ -83,7 +84,7 @@ namespace osu.Game.Online
try
{
while (apiState.Value == APIState.Online)
while (apiState.Value == APIState.RequiresSecondFactorAuth || apiState.Value == APIState.Online)
{
// ensure any previous connection was disposed.
// this will also create a new cancellation token source.

View File

@ -264,13 +264,12 @@ namespace osu.Game.Online.Spectator
{
Debug.Assert(ThreadSafety.IsUpdateThread);
if (watchedUsersRefCounts.ContainsKey(userId))
if (!watchedUsersRefCounts.TryAdd(userId, 1))
{
watchedUsersRefCounts[userId]++;
return;
}
watchedUsersRefCounts.Add(userId, 1);
WatchUserInternal(userId);
}

View File

@ -102,7 +102,7 @@ namespace osu.Game
public virtual bool UseDevelopmentServer => DebugUtils.IsDebugBuild;
public virtual EndpointConfiguration CreateEndpoints() =>
UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ExperimentalEndpointConfiguration();
UseDevelopmentServer ? new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration();
public virtual Version AssemblyVersion => Assembly.GetEntryAssembly()?.GetName().Version ?? new Version();
@ -340,10 +340,6 @@ namespace osu.Game
dependencies.Cache(beatmapCache = new BeatmapLookupCache());
base.Content.Add(beatmapCache);
var scorePerformanceManager = new ScorePerformanceCache();
dependencies.Cache(scorePerformanceManager);
base.Content.Add(scorePerformanceManager);
dependencies.CacheAs<IRulesetConfigCache>(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore));
var powerStatus = CreateBatteryInfo();

View File

@ -0,0 +1,24 @@
// 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.Graphics;
using osu.Game.Overlays.Login;
namespace osu.Game.Overlays.AccountCreation
{
public partial class ScreenEmailVerification : AccountCreationScreen
{
[BackgroundDependencyLoader]
private void load()
{
InternalChild = new SecondFactorAuthForm
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding(20),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
};
}
}
}

View File

@ -1,12 +1,11 @@
// 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.
#nullable disable
using System;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -28,28 +27,30 @@ namespace osu.Game.Overlays.AccountCreation
{
public partial class ScreenEntry : AccountCreationScreen
{
private ErrorTextFlowContainer usernameDescription;
private ErrorTextFlowContainer emailAddressDescription;
private ErrorTextFlowContainer passwordDescription;
private ErrorTextFlowContainer usernameDescription = null!;
private ErrorTextFlowContainer emailAddressDescription = null!;
private ErrorTextFlowContainer passwordDescription = null!;
private OsuTextBox usernameTextBox;
private OsuTextBox emailTextBox;
private OsuPasswordTextBox passwordTextBox;
private OsuTextBox usernameTextBox = null!;
private OsuTextBox emailTextBox = null!;
private OsuPasswordTextBox passwordTextBox = null!;
[Resolved]
private IAPIProvider api { get; set; }
private IAPIProvider api { get; set; } = null!;
private ShakeContainer registerShake;
private ITextPart characterCheckText;
private IBindable<APIState> apiState = null!;
private OsuTextBox[] textboxes;
private LoadingLayer loadingLayer;
private ShakeContainer registerShake = null!;
private ITextPart characterCheckText = null!;
private OsuTextBox[] textboxes = null!;
private LoadingLayer loadingLayer = null!;
[Resolved]
private GameHost host { get; set; }
private GameHost? host { get; set; }
[Resolved]
private OsuGame game { get; set; }
private OsuGame? game { get; set; }
[BackgroundDependencyLoader]
private void load()
@ -144,6 +145,8 @@ namespace osu.Game.Overlays.AccountCreation
passwordTextBox.Current.BindValueChanged(_ => updateCharacterCheckTextColour(), true);
characterCheckText.DrawablePartsRecreated += _ => updateCharacterCheckTextColour();
apiState = api.State.GetBoundCopy();
}
private void updateCharacterCheckTextColour()
@ -180,7 +183,7 @@ namespace osu.Game.Overlays.AccountCreation
Task.Run(() =>
{
bool success;
RegistrationRequest.RegistrationRequestErrors errors = null;
RegistrationRequest.RegistrationRequestErrors? errors = null;
try
{
@ -210,7 +213,7 @@ namespace osu.Game.Overlays.AccountCreation
if (!string.IsNullOrEmpty(errors.Message))
passwordDescription.AddErrors(new[] { errors.Message });
game.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", true);
game?.OpenUrlExternally($"{errors.Redirect}?username={usernameTextBox.Text}&email={emailTextBox.Text}", true);
}
}
else
@ -223,6 +226,12 @@ namespace osu.Game.Overlays.AccountCreation
return;
}
apiState.BindValueChanged(state =>
{
if (state.NewValue == APIState.RequiresSecondFactorAuth)
this.Push(new ScreenEmailVerification());
});
api.Login(usernameTextBox.Text, passwordTextBox.Text);
});
});
@ -241,6 +250,6 @@ namespace osu.Game.Overlays.AccountCreation
return false;
}
private OsuTextBox nextUnfilledTextBox() => textboxes.FirstOrDefault(t => string.IsNullOrEmpty(t.Text));
private OsuTextBox? nextUnfilledTextBox() => textboxes.FirstOrDefault(t => string.IsNullOrEmpty(t.Text));
}
}

View File

@ -1,8 +1,6 @@
// 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.
#nullable disable
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -23,14 +21,14 @@ namespace osu.Game.Overlays.AccountCreation
{
public partial class ScreenWarning : AccountCreationScreen
{
private OsuTextFlowContainer multiAccountExplanationText;
private LinkFlowContainer furtherAssistance;
private OsuTextFlowContainer multiAccountExplanationText = null!;
private LinkFlowContainer furtherAssistance = null!;
[Resolved(canBeNull: true)]
private IAPIProvider api { get; set; }
[Resolved]
private IAPIProvider? api { get; set; }
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
[Resolved]
private OsuGame? game { get; set; }
private const string help_centre_url = "/help/wiki/Help_Centre#login";

View File

@ -1,8 +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.
#nullable disable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -24,7 +23,9 @@ namespace osu.Game.Overlays
{
private const float transition_time = 400;
private ScreenWelcome welcomeScreen;
private ScreenWelcome welcomeScreen = null!;
private ScheduledDelegate? scheduledHide;
public AccountCreationOverlay()
{
@ -107,8 +108,6 @@ namespace osu.Game.Overlays
this.FadeOut(100);
}
private ScheduledDelegate scheduledHide;
private void apiStateChanged(ValueChangedEvent<APIState> state)
{
switch (state.NewValue)
@ -118,12 +117,16 @@ namespace osu.Game.Overlays
break;
case APIState.Connecting:
case APIState.RequiresSecondFactorAuth:
break;
case APIState.Online:
scheduledHide?.Cancel();
scheduledHide = Schedule(Hide);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
}

View File

@ -30,7 +30,7 @@ namespace osu.Game.Overlays.Comments
public Color4 AccentColour { get; set; }
protected override IEnumerable<Drawable> EffectTargets => null;
protected override IEnumerable<Drawable> EffectTargets => Enumerable.Empty<Drawable>();
[Resolved]
private IAPIProvider api { get; set; }

View File

@ -32,13 +32,7 @@ namespace osu.Game.Overlays.Login
public Action? RequestHide;
private void performLogin()
{
if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text))
api.Login(username.Text, password.Text);
else
shakeSignIn.Shake();
}
public override bool AcceptsFocus => true;
[BackgroundDependencyLoader(permitNulls: true)]
private void load(OsuConfigManager config, AccountCreationOverlay accountCreation)
@ -144,7 +138,13 @@ namespace osu.Game.Overlays.Login
}
}
public override bool AcceptsFocus => true;
private void performLogin()
{
if (!string.IsNullOrEmpty(username.Text) && !string.IsNullOrEmpty(password.Text))
api.Login(username.Text, password.Text);
else
shakeSignIn.Shake();
}
protected override bool OnClick(ClickEvent e) => true;

View File

@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Login
{
private bool bounding = true;
private LoginForm? form;
private Drawable? form;
[Resolved]
private OsuColour colours { get; set; } = null!;
@ -81,6 +81,10 @@ namespace osu.Game.Overlays.Login
};
break;
case APIState.RequiresSecondFactorAuth:
Child = form = new SecondFactorAuthForm();
break;
case APIState.Failing:
case APIState.Connecting:
LinkFlowContainer linkFlow;

View File

@ -0,0 +1,147 @@
// 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.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Settings;
using osu.Game.Resources.Localisation.Web;
using osuTK;
namespace osu.Game.Overlays.Login
{
public partial class SecondFactorAuthForm : Container
{
private OsuTextBox codeTextBox = null!;
private LinkFlowContainer explainText = null!;
private ErrorTextFlowContainer errorText = null!;
private LoadingLayer loading = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0, SettingsSection.ITEM_SPACING),
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS },
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, SettingsSection.ITEM_SPACING),
Children = new Drawable[]
{
new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Text = "An email has been sent to you with a verification code. Enter the code.",
},
codeTextBox = new OsuTextBox
{
PlaceholderText = "Enter code",
RelativeSizeAxes = Axes.X,
TabbableContentContainer = this,
},
explainText = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(weight: FontWeight.Regular))
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
errorText = new ErrorTextFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Alpha = 0,
},
},
},
new LinkFlowContainer
{
Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS },
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
},
}
},
loading = new LoadingLayer(true)
{
Padding = new MarginPadding { Vertical = -SettingsSection.ITEM_SPACING },
}
};
explainText.AddParagraph(UserVerificationStrings.BoxInfoCheckSpam);
// We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something).
explainText.AddParagraph("If you can't access your email or have forgotten what you used, please follow the ");
explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.WebsiteRootUrl}/home/password-reset");
explainText.AddText(". You can also ");
explainText.AddLink(UserVerificationStrings.BoxInfoReissueLink, () =>
{
loading.Show();
var reissueRequest = new ReissueVerificationCodeRequest();
reissueRequest.Failure += ex =>
{
Logger.Error(ex, @"Failed to retrieve new verification code.");
loading.Hide();
};
reissueRequest.Success += () =>
{
loading.Hide();
};
Task.Run(() => api.Perform(reissueRequest));
});
explainText.AddText(" or ");
explainText.AddLink(UserVerificationStrings.BoxInfoLogoutLink, () => { api.Logout(); });
explainText.AddText(".");
codeTextBox.Current.BindValueChanged(code =>
{
if (code.NewValue.Length == 8)
{
api.AuthenticateSecondFactor(code.NewValue);
codeTextBox.Current.Disabled = true;
}
});
if (api.LastLoginError?.Message is string error)
{
errorText.Alpha = 1;
errorText.AddErrors(new[] { error });
}
}
public override bool AcceptsFocus => true;
protected override bool OnClick(ClickEvent e) => true;
protected override void OnFocus(FocusEvent e)
{
Schedule(() => { GetContainingInputManager().ChangeFocus(codeTextBox); });
}
}
}

Some files were not shown because too many files have changed in this diff Show More