1452 lines
58 KiB
C#
1452 lines
58 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using NUnit.Framework;
|
|
using ProjectQuint.Core.Bms;
|
|
using ProjectQuint.Core.Charts;
|
|
using ProjectQuint.Core.Course;
|
|
using ProjectQuint.Core.Gauge;
|
|
using ProjectQuint.Core.Judgement;
|
|
using ProjectQuint.Core.Timing;
|
|
|
|
namespace ProjectQuint.Tests
|
|
{
|
|
public sealed class BmsCoreTests
|
|
{
|
|
[Test]
|
|
public void Parser_DecodesShiftJisJapaneseMetadata()
|
|
{
|
|
var directory = Path.Combine(Path.GetTempPath(), "ProjectQuint-ShiftJis-" + Guid.NewGuid().ToString("N"));
|
|
var path = Path.Combine(directory, "chart.bms");
|
|
Directory.CreateDirectory(directory);
|
|
try
|
|
{
|
|
var bytes = new List<byte>();
|
|
bytes.AddRange(Encoding.ASCII.GetBytes("#TITLE PUPA\r\n#ARTIST "));
|
|
bytes.AddRange(new byte[]
|
|
{
|
|
0x83, 0x82, 0x83, 0x8A, 0x83, 0x82, 0x83, 0x8A,
|
|
0x82, 0xA0, 0x82, 0xC2, 0x82, 0xB5,
|
|
});
|
|
bytes.AddRange(Encoding.ASCII.GetBytes("\r\n#BPM 202\r\n"));
|
|
File.WriteAllBytes(path, bytes.ToArray());
|
|
|
|
var metadata = BmsParser.ReadMetadataFile(path);
|
|
|
|
Assert.That(metadata.Title, Is.EqualTo("PUPA"));
|
|
Assert.That(metadata.Artist, Is.EqualTo("モリモリあつし"));
|
|
}
|
|
finally
|
|
{
|
|
if (Directory.Exists(directory))
|
|
Directory.Delete(directory, true);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void Parser_Base62PreservesCaseSensitiveObjectIdsAcrossAllTimelines()
|
|
{
|
|
const string source = @"
|
|
#TITLE Base62 Test
|
|
#BPM 120
|
|
#WAVAA upper.wav
|
|
#WAVAa lower.wav
|
|
#BMPBA upper.png
|
|
#BMPBa lower.png
|
|
#BPMCA 150
|
|
#BPMCa 180
|
|
#STOPDA 48
|
|
#STOPDa 96
|
|
#SCROLLEA 1.25
|
|
#SCROLLEa 0.5
|
|
#LNOBJ Aa
|
|
#00101:AAAa
|
|
#00104:BABa
|
|
#00108:CACa
|
|
#00109:DADa
|
|
#001SC:EAEa
|
|
#00211:AAAAAa
|
|
#RANDOM 1
|
|
#IF 1
|
|
#BASE 62
|
|
#ENDIF
|
|
#ENDRANDOM
|
|
";
|
|
|
|
var document = BmsParser.ParseText("base62.bms", source);
|
|
var compiled = BmsTimelineCompiler.Compile(document);
|
|
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
|
|
|
|
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(62));
|
|
Assert.That(document.WavDefinitions, Has.Count.EqualTo(2));
|
|
Assert.That(document.WavDefinitions["AA"], Is.EqualTo("upper.wav"));
|
|
Assert.That(document.WavDefinitions["Aa"], Is.EqualTo("lower.wav"));
|
|
Assert.That(document.BmpDefinitions["BA"], Is.EqualTo("upper.png"));
|
|
Assert.That(document.BmpDefinitions["Ba"], Is.EqualTo("lower.png"));
|
|
Assert.That(document.ExtendedBpms.Keys, Is.EquivalentTo(new[] { "CA", "Ca" }));
|
|
Assert.That(document.Stops.Keys, Is.EquivalentTo(new[] { "DA", "Da" }));
|
|
Assert.That(document.Scrolls.Keys, Is.EquivalentTo(new[] { "EA", "Ea" }));
|
|
Assert.That(document.Metadata.LnObj, Is.EqualTo("Aa"));
|
|
|
|
Assert.That(chart.AudioResources, Has.Count.EqualTo(2));
|
|
Assert.That(chart.AudioResources["AA"], Is.EqualTo("upper.wav"));
|
|
Assert.That(chart.AudioResources["Aa"], Is.EqualTo("lower.wav"));
|
|
Assert.That(chart.BackgroundAudioEvents.Select(item => item.SoundId),
|
|
Is.EqualTo(new[] { "AA", "Aa" }));
|
|
Assert.That(chart.VisualEvents.Select(item => item.ResourceId),
|
|
Is.EqualTo(new[] { "BA", "Ba" }));
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(2));
|
|
Assert.That(chart.Notes.Count(item => item.IsLong), Is.EqualTo(1));
|
|
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("Aa"));
|
|
Assert.That(chart.Warnings.Any(item => item.StartsWith("Undefined", StringComparison.Ordinal)), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void Parser_Base36NormalizesLowercaseObjectIdsEvenWhenBaseIsDeclaredLast()
|
|
{
|
|
const string source = @"
|
|
#BPM 120
|
|
#WAVAa hit.wav
|
|
#BMPbB image.png
|
|
#LNOBJ cC
|
|
#00111:AaAaCc
|
|
#00104:bB
|
|
#BASE 36
|
|
";
|
|
|
|
var document = BmsParser.ParseText("base36-lowercase.bms", source);
|
|
var chart = BmsTimelineCompiler.Compile(document);
|
|
|
|
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(36));
|
|
Assert.That(document.WavDefinitions.Keys, Is.EqualTo(new[] { "AA" }));
|
|
Assert.That(document.BmpDefinitions.Keys, Is.EqualTo(new[] { "BB" }));
|
|
Assert.That(document.Metadata.LnObj, Is.EqualTo("CC"));
|
|
Assert.That(document.Events.All(item => item.ObjectId == item.ObjectId.ToUpperInvariant()), Is.True);
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(2));
|
|
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("CC"));
|
|
Assert.That(chart.BgaEvents.Single().ResourceId, Is.EqualTo("BB"));
|
|
}
|
|
|
|
[Test]
|
|
public void Parser_RejectsUnknownObjectBaseInsteadOfSilentlyCorruptingIds()
|
|
{
|
|
const string source = "#BASE 64\n#WAVAA hit.wav\n#00111:AA";
|
|
|
|
Assert.That(
|
|
() => BmsParser.ParseText("unsupported-base.bms", source),
|
|
Throws.TypeOf<FormatException>()
|
|
.With.Message.Contains("supports only #BASE 36 and #BASE 62"));
|
|
}
|
|
|
|
[Test]
|
|
public void Parser_RejectsObsoleteLnTypeTwoInsteadOfMisreadingLongNotes()
|
|
{
|
|
const string source = "#LNTYPE 2\n#BPM 120\n#00151:0001010100";
|
|
|
|
Assert.That(
|
|
() => BmsParser.ParseText("obsolete-mgq-ln.bms", source),
|
|
Throws.TypeOf<FormatException>()
|
|
.With.Message.Contains("Unsupported BMS #LNTYPE 2")
|
|
.And.Message.Contains("#LNTYPE 1 and #LNOBJ"));
|
|
}
|
|
|
|
[Test]
|
|
public void Parser_AcceptsSupportedLnTypeOne()
|
|
{
|
|
const string source = "#LNTYPE 1\n#BPM 120\n#00151:00010001";
|
|
|
|
var document = BmsParser.ParseText("standard-ln.bms", source);
|
|
var chart = BmsTimelineCompiler.Compile(document);
|
|
|
|
Assert.That(document.Metadata.LnType, Is.EqualTo(1));
|
|
Assert.That(chart.Notes.Count(note => note.IsLong), Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void Converter_PreservesScratchAsFixedLeftLane()
|
|
{
|
|
const string source = @"
|
|
#TITLE Smoke Test
|
|
#ARTIST ProjectQuint
|
|
#BPM 120
|
|
#WAV01 kick.wav
|
|
#00111:01
|
|
#00112:0001
|
|
#00116:01
|
|
";
|
|
|
|
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("smoke.bms", source));
|
|
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
|
|
|
|
Assert.That(chart.Metadata.Title, Is.EqualTo("Smoke Test"));
|
|
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(5));
|
|
Assert.That(chart.Metadata.HasScratch, Is.True);
|
|
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(6));
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(3));
|
|
Assert.That(chart.Notes[0].Lane, Is.EqualTo(0));
|
|
Assert.That(chart.Notes[0].IsScratch, Is.True);
|
|
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
|
|
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(1));
|
|
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(2));
|
|
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void Parser_PreservesDeclaredTotalForGaugeCalculation()
|
|
{
|
|
const string source = @"
|
|
#TITLE Gauge Test
|
|
#BPM 120
|
|
#TOTAL 470.5
|
|
#00111:01
|
|
";
|
|
|
|
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("total.bms", source));
|
|
|
|
Assert.That(chart.Metadata.Total, Is.EqualTo(470.5).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_NormalUsesTotalPerNoteAndSeventyFivePercentBorder()
|
|
{
|
|
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 100, totalNotes: 100);
|
|
|
|
Assert.That(gauge.Value, Is.EqualTo(20));
|
|
Assert.That(gauge.IsCleared, Is.False);
|
|
|
|
for (var index = 0; index < 55; index++)
|
|
gauge.Apply(JudgementGrade.Kool);
|
|
|
|
Assert.That(gauge.Value, Is.EqualTo(75).Within(0.000001));
|
|
Assert.That(gauge.IsCleared, Is.True);
|
|
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.EqualTo(69).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_CourseModeCarriesValueAndAppliesLr2DamageReduction()
|
|
{
|
|
var gauge = new PlayGauge(
|
|
GaugeMode.Class,
|
|
declaredTotal: 0,
|
|
totalNotes: 100,
|
|
restoredValue: 29.0);
|
|
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.EqualTo(27.2).Within(0.000001));
|
|
gauge.ApplyEmptyInputPenalty();
|
|
Assert.That(gauge.Value, Is.EqualTo(26.0).Within(0.000001));
|
|
gauge.Apply(JudgementGrade.Kool);
|
|
Assert.That(gauge.Value, Is.EqualTo(26.1).Within(0.000001));
|
|
Assert.That(gauge.IsCleared, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_FiveKeyCourseProfileUsesConfiguredDeltas()
|
|
{
|
|
var gauge = new PlayGauge(
|
|
GaugeMode.Class,
|
|
declaredTotal: 0,
|
|
totalNotes: 100,
|
|
restoredValue: 50.0,
|
|
courseGaugeProfile: CourseGaugeProfile.FiveKey);
|
|
|
|
gauge.Apply(JudgementGrade.Kool);
|
|
gauge.Apply(JudgementGrade.Good);
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
gauge.ApplyEmptyInputPenalty();
|
|
|
|
Assert.That(gauge.Value, Is.EqualTo(48.51).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_SevenKeyCourseProfileUsesThresholdDamageReduction()
|
|
{
|
|
var gauge = new PlayGauge(
|
|
GaugeMode.Class,
|
|
declaredTotal: 0,
|
|
totalNotes: 100,
|
|
restoredValue: 24.0,
|
|
courseGaugeProfile: CourseGaugeProfile.SevenKey);
|
|
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.EqualTo(21.6).Within(0.000001));
|
|
gauge.Apply(JudgementGrade.Cool);
|
|
Assert.That(gauge.Value, Is.EqualTo(21.72).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_NormalNeverDropsBelowTwoPercent()
|
|
{
|
|
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 300, totalNotes: 100);
|
|
|
|
for (var index = 0; index < 20; index++)
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
|
|
Assert.That(gauge.Value, Is.EqualTo(2));
|
|
Assert.That(gauge.IsFailed, Is.False);
|
|
Assert.That(gauge.IsCleared, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_HardRecoversAndUsesLowGaugeDamageReduction()
|
|
{
|
|
var gauge = new PlayGauge(GaugeMode.Hard, declaredTotal: 300, totalNotes: 100);
|
|
|
|
for (var index = 0; index < 6; index++)
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.EqualTo(40));
|
|
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.EqualTo(32));
|
|
|
|
gauge.Apply(JudgementGrade.Kool);
|
|
gauge.Apply(JudgementGrade.Cool);
|
|
gauge.Apply(JudgementGrade.Good);
|
|
Assert.That(gauge.Value, Is.EqualTo(32.3).Within(0.000001));
|
|
Assert.That(gauge.IsFailed, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_StandardHardRecoversAndReducesDamageAtLowGauge()
|
|
{
|
|
var gauge = new PlayGauge(
|
|
GaugeMode.Hard,
|
|
declaredTotal: 300,
|
|
totalNotes: 100,
|
|
restoredValue: 49.0);
|
|
|
|
gauge.Apply(JudgementGrade.Kool);
|
|
Assert.That(gauge.Value, Is.EqualTo(49.15).Within(0.000001));
|
|
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.EqualTo(41.15).Within(0.000001));
|
|
Assert.That(gauge.IsFailed, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_StandardHardSurvivesTenConsecutiveMisses()
|
|
{
|
|
var gauge = new PlayGauge(
|
|
GaugeMode.Hard,
|
|
declaredTotal: 300,
|
|
totalNotes: 100);
|
|
|
|
for (var index = 0; index < 10; index++)
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
|
|
Assert.That(gauge.Value, Is.EqualTo(14.0).Within(0.000001));
|
|
Assert.That(gauge.IsFailed, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_ExHardUsesReducedRecoveryAndNoLowGaugeDamageReduction()
|
|
{
|
|
var gauge = new PlayGauge(
|
|
GaugeMode.ExHard,
|
|
declaredTotal: 300,
|
|
totalNotes: 100,
|
|
restoredValue: 50.0);
|
|
|
|
gauge.Apply(JudgementGrade.Kool);
|
|
gauge.Apply(JudgementGrade.Cool);
|
|
gauge.Apply(JudgementGrade.Good);
|
|
Assert.That(gauge.Value, Is.EqualTo(50.21).Within(0.000001));
|
|
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.EqualTo(34.21).Within(0.000001));
|
|
gauge.ApplyEmptyInputPenalty();
|
|
Assert.That(gauge.Value, Is.EqualTo(26.21).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_ExHardFailsOnSeventhConsecutiveMiss()
|
|
{
|
|
var gauge = new PlayGauge(
|
|
GaugeMode.ExHard,
|
|
declaredTotal: 300,
|
|
totalNotes: 100);
|
|
|
|
for (var index = 0; index < 6; index++)
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
|
|
Assert.That(gauge.Value, Is.EqualTo(4.0).Within(0.000001));
|
|
Assert.That(gauge.IsFailed, Is.False);
|
|
|
|
gauge.Apply(JudgementGrade.Miss);
|
|
Assert.That(gauge.Value, Is.Zero);
|
|
Assert.That(gauge.IsFailed, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void GaugeFallbackChain_UsesExactParallelHardAndNormalStates()
|
|
{
|
|
var chain = new GaugeFallbackChain(
|
|
GaugeMode.ExHard,
|
|
declaredTotal: 300,
|
|
totalNotes: 100,
|
|
fallbackEnabled: true);
|
|
var hardReference = new PlayGauge(GaugeMode.Hard, 300, 100);
|
|
var normalReference = new PlayGauge(GaugeMode.Normal, 300, 100);
|
|
|
|
GaugeTransition transition = default;
|
|
for (var index = 0; index < 7; index++)
|
|
{
|
|
transition = chain.Apply(JudgementGrade.Miss);
|
|
hardReference.Apply(JudgementGrade.Miss);
|
|
normalReference.Apply(JudgementGrade.Miss);
|
|
}
|
|
|
|
Assert.That(transition.Changed, Is.True);
|
|
Assert.That(transition.From, Is.EqualTo(GaugeMode.ExHard));
|
|
Assert.That(transition.To, Is.EqualTo(GaugeMode.Hard));
|
|
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Hard));
|
|
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(hardReference.Value).Within(0.000001));
|
|
|
|
while (chain.ActiveGauge.Mode == GaugeMode.Hard)
|
|
{
|
|
transition = chain.Apply(JudgementGrade.Miss);
|
|
hardReference.Apply(JudgementGrade.Miss);
|
|
normalReference.Apply(JudgementGrade.Miss);
|
|
}
|
|
|
|
Assert.That(transition.Changed, Is.True);
|
|
Assert.That(transition.From, Is.EqualTo(GaugeMode.Hard));
|
|
Assert.That(transition.To, Is.EqualTo(GaugeMode.Normal));
|
|
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
|
|
|
|
for (var index = 0; index < 25; index++)
|
|
{
|
|
chain.Apply(JudgementGrade.Kool);
|
|
normalReference.Apply(JudgementGrade.Kool);
|
|
}
|
|
|
|
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Normal));
|
|
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
|
|
Assert.That(chain.ActiveGauge.IsCleared, Is.True);
|
|
}
|
|
|
|
[TestCase(GaugeMode.Class)]
|
|
[TestCase(GaugeMode.Normal)]
|
|
public void GaugeFallbackChain_DoesNotCreateUnsupportedFallbacks(GaugeMode mode)
|
|
{
|
|
var chain = new GaugeFallbackChain(
|
|
mode,
|
|
declaredTotal: 300,
|
|
totalNotes: 100,
|
|
fallbackEnabled: true);
|
|
|
|
Assert.That(chain.GaugeCount, Is.EqualTo(1));
|
|
Assert.That(chain.HasFallback, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void GaugeFallbackChain_DisabledExHardStillFailsWithoutTransition()
|
|
{
|
|
var chain = new GaugeFallbackChain(
|
|
GaugeMode.ExHard,
|
|
declaredTotal: 300,
|
|
totalNotes: 100,
|
|
fallbackEnabled: false);
|
|
|
|
GaugeTransition transition = default;
|
|
for (var index = 0; index < 7; index++)
|
|
transition = chain.Apply(JudgementGrade.Miss);
|
|
|
|
Assert.That(transition.Changed, Is.False);
|
|
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.ExHard));
|
|
Assert.That(chain.ActiveGauge.IsFailed, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void PlayGauge_UsesOfficialDefaultTotalWhenHeaderIsMissing()
|
|
{
|
|
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 0, totalNotes: 1000);
|
|
|
|
Assert.That(gauge.Total, Is.EqualTo(7.605 * 1000 / 16.5).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void Compiler_PreservesBmpDefinitionsAndBgaLayerEvents()
|
|
{
|
|
const string source = @"
|
|
#BPM 120
|
|
#BMP01 movie.mpg
|
|
#BMP02 poor.png
|
|
#BMP03 layer.png
|
|
#00104:01
|
|
#00106:02
|
|
#00107:03
|
|
";
|
|
|
|
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("bga.bms", source));
|
|
|
|
Assert.That(chart.BmpDefinitions["01"], Is.EqualTo("movie.mpg"));
|
|
Assert.That(chart.BgaEvents, Has.Count.EqualTo(3));
|
|
Assert.That(chart.BgaEvents[0].Time, Is.EqualTo(2.0).Within(0.000001));
|
|
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Base));
|
|
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Poor));
|
|
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Layer));
|
|
}
|
|
|
|
[Test]
|
|
public void LaneModifier_MirrorReversesAllFiveLanes()
|
|
{
|
|
var laneMap = LanePermutation.Create(LaneModifier.Mirror);
|
|
|
|
Assert.That(laneMap, Is.EqualTo(new[] { 4, 3, 2, 1, 0 }));
|
|
}
|
|
|
|
[Test]
|
|
public void LaneModifier_MirrorKeepsScratchFixedAndReversesSevenKeys()
|
|
{
|
|
var laneMap = LanePermutation.Create(LaneModifier.Mirror, keyCount: 7, hasScratch: true);
|
|
|
|
Assert.That(laneMap, Is.EqualTo(new[] { 0, 7, 6, 5, 4, 3, 2, 1 }));
|
|
}
|
|
|
|
[Test]
|
|
public void ParserAndConverter_SupportOnePlusSevenAsEightGameplayLanes()
|
|
{
|
|
const string source = @"
|
|
#TITLE 1+7 Test
|
|
#BPM 120
|
|
#WAV01 hit.wav
|
|
#00111:01
|
|
#00112:01
|
|
#00113:01
|
|
#00114:01
|
|
#00115:01
|
|
#00118:01
|
|
#00119:01
|
|
#00116:01
|
|
";
|
|
|
|
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("one-plus-seven.bms", source));
|
|
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
|
|
|
|
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(7));
|
|
Assert.That(chart.Metadata.HasScratch, Is.True);
|
|
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(8));
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(8));
|
|
Assert.That(chart.Notes.Select(note => note.Lane), Is.EquivalentTo(Enumerable.Range(0, 8)));
|
|
Assert.That(chart.Notes.Single(note => note.IsScratch).Lane, Is.Zero);
|
|
}
|
|
|
|
[TestCase(7, false, "7K")]
|
|
[TestCase(7, true, "1+7K")]
|
|
[TestCase(5, false, "5K")]
|
|
[TestCase(5, true, "1+5K")]
|
|
public void KeyModeLabel_DistinguishesKeysFromScratchAndKeys(
|
|
int keyCount,
|
|
bool hasScratch,
|
|
string expected)
|
|
{
|
|
Assert.That(
|
|
ChartKeyMode.FormatLabel(keyCount, hasScratch),
|
|
Is.EqualTo(expected));
|
|
}
|
|
|
|
[TestCase("#00111:01\n#00112:01\n#00114:01\n#00115:01", 4)]
|
|
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01", 5)]
|
|
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00115:01\n#00118:01\n#00119:01", 6)]
|
|
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01\n#00118:01\n#00119:01", 7)]
|
|
[TestCase("#00151:0102\n#00152:0102\n#00154:0102\n#00155:0102", 4)]
|
|
public void Parser_DetectsSupportedKeyLayouts(string events, int expectedKeyCount)
|
|
{
|
|
var document = BmsParser.ParseText(
|
|
"layout.bms",
|
|
"#BPM 120\n#WAV01 hit.wav\n#WAV02 tail.wav\n" + events);
|
|
var chart = BmsTimelineCompiler.Compile(document);
|
|
|
|
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(expectedKeyCount));
|
|
Assert.That(chart.Notes.Select(note => note.Lane).Distinct().Count(), Is.EqualTo(expectedKeyCount));
|
|
}
|
|
|
|
[Test]
|
|
public void SixKeyContract_SkipsCenterChannelAndKeepsRightmostChannel()
|
|
{
|
|
var document = BmsParser.ParseText(
|
|
"six-key-with-center-stray.bms",
|
|
"#BPM 120\n" +
|
|
"#WAV01 one.wav\n#WAV02 two.wav\n#WAV03 three.wav\n" +
|
|
"#WAV04 stray.wav\n#WAV05 five.wav\n#WAV06 six.wav\n#WAV07 seven.wav\n" +
|
|
"#00111:01\n#00112:02\n#00113:03\n#00114:04\n" +
|
|
"#00115:05\n#00118:06\n#00119:07\n");
|
|
document.Metadata.KeyCount = 6;
|
|
|
|
var chart = BmsTimelineCompiler.Compile(document);
|
|
|
|
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(6));
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(6));
|
|
Assert.That(chart.Notes.Select(note => note.Lane),
|
|
Is.EquivalentTo(Enumerable.Range(0, 6)));
|
|
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Contain("07"));
|
|
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Not.Contain("04"));
|
|
}
|
|
|
|
[Test]
|
|
public void LaneModifier_RandomCreatesOneDeterministicPermutationPerPlay()
|
|
{
|
|
var first = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
|
|
var second = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
|
|
|
|
Assert.That(first, Is.EqualTo(second));
|
|
Assert.That(first, Is.EquivalentTo(new[] { 0, 1, 2, 3, 4 }));
|
|
Assert.That(first, Is.Not.EqualTo(new[] { 0, 1, 2, 3, 4 }));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayableChartConverter_AppliesTheSameMappingWithoutMutatingSourceNotes()
|
|
{
|
|
var notes = Enumerable.Range(0, 5)
|
|
.Select(lane => new BmsNote { Id = lane, Lane = lane })
|
|
.ToArray();
|
|
var source = new BmsChart();
|
|
source.Notes.AddRange(notes);
|
|
|
|
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Random, randomSeed: 7);
|
|
|
|
for (var lane = 0; lane < notes.Length; lane++)
|
|
{
|
|
Assert.That(conversion.Chart.Notes.Single(note => note.Id == lane).Lane,
|
|
Is.EqualTo(conversion.LaneMap[lane]));
|
|
Assert.That(notes[lane].Lane, Is.EqualTo(lane));
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void InvisibleBmsChannels_BecomeModifierAwareLaneSoundEvents()
|
|
{
|
|
var document = BmsParser.ParseText(
|
|
"invisible.bms",
|
|
"#BPM 120\n#WAV01 visible.wav\n#WAV02 empty.wav\n#00111:01\n#00231:02");
|
|
var source = BmsTimelineCompiler.Compile(document);
|
|
|
|
Assert.That(source.LaneSoundEvents, Has.Count.EqualTo(1));
|
|
Assert.That(source.LaneSoundEvents[0].Lane, Is.Zero);
|
|
Assert.That(source.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
|
|
|
|
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror);
|
|
Assert.That(conversion.Chart.LaneSoundEvents, Has.Count.EqualTo(1));
|
|
Assert.That(conversion.Chart.LaneSoundEvents[0].Lane, Is.EqualTo(4));
|
|
Assert.That(conversion.Chart.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayableChartConverter_PreservesLongNoteIdentityTimingAndResourceTimelines()
|
|
{
|
|
var source = new BmsChart
|
|
{
|
|
Metadata = new BmsMetadata
|
|
{
|
|
ChartPath = "/songs/example/chart.bms",
|
|
Title = "Example",
|
|
Artist = "Composer",
|
|
StageFile = "stage.png",
|
|
Total = 420,
|
|
},
|
|
};
|
|
source.WavDefinitions["01"] = "head.wav";
|
|
source.BmpDefinitions["02"] = "movie.mp4";
|
|
source.Notes.Add(new BmsNote
|
|
{
|
|
Id = 42,
|
|
Lane = 1,
|
|
StartTime = 1.25,
|
|
EndTime = 3.5,
|
|
SoundId = "01",
|
|
TailSoundId = "09",
|
|
});
|
|
source.BackgroundSounds.Add(new BmsAudioEvent { Time = 0.5, SoundId = "01" });
|
|
source.BgaEvents.Add(new BmsBgaEvent
|
|
{
|
|
Time = 0.75,
|
|
ResourceId = "02",
|
|
Layer = BmsBgaLayer.Layer,
|
|
});
|
|
|
|
var chart = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror).Chart;
|
|
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(1));
|
|
Assert.That(chart.Notes[0].Id, Is.EqualTo(42));
|
|
Assert.That(chart.Notes[0].Lane, Is.EqualTo(3));
|
|
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(1.25));
|
|
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.5));
|
|
Assert.That(chart.Notes[0].SoundId, Is.EqualTo("01"));
|
|
Assert.That(chart.Notes[0].TailSoundId, Is.EqualTo("09"));
|
|
Assert.That(chart.Metadata.SourcePath, Is.EqualTo("/songs/example/chart.bms"));
|
|
Assert.That(chart.Metadata.GaugeTotal, Is.EqualTo(420));
|
|
Assert.That(chart.AudioResources["01"], Is.EqualTo("head.wav"));
|
|
Assert.That(chart.VisualResources["02"], Is.EqualTo("movie.mp4"));
|
|
Assert.That(chart.BackgroundAudioEvents, Has.Count.EqualTo(1));
|
|
Assert.That(chart.VisualEvents[0].Layer, Is.EqualTo(PlayableVisualLayer.Overlay));
|
|
}
|
|
|
|
[Test]
|
|
public void PlayableChartConverter_PreservesScratchSoundAndDuration()
|
|
{
|
|
var source = new BmsChart
|
|
{
|
|
Metadata = new BmsMetadata { KeyCount = 5, HasScratch = true },
|
|
ScratchObjectCount = 1,
|
|
Duration = 102.0,
|
|
};
|
|
source.Notes.Add(new BmsNote
|
|
{
|
|
Id = 1,
|
|
Lane = 1,
|
|
StartTime = 1.0,
|
|
EndTime = 1.0,
|
|
SoundId = "KEY",
|
|
});
|
|
source.Notes.Add(new BmsNote
|
|
{
|
|
Id = 2,
|
|
Lane = 0,
|
|
StartTime = 100.0,
|
|
EndTime = 100.0,
|
|
SoundId = "SCRATCH",
|
|
IsScratch = true,
|
|
});
|
|
|
|
var chart = BmsPlayableChartConverter.Convert(source).Chart;
|
|
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(2));
|
|
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("KEY"));
|
|
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("SCRATCH"));
|
|
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
|
|
Assert.That(chart.Duration, Is.EqualTo(102.0));
|
|
Assert.That(source.Notes, Has.Count.EqualTo(2));
|
|
Assert.That(source.Duration, Is.EqualTo(102.0));
|
|
}
|
|
|
|
[Test]
|
|
public void Compiler_PairsLongNoteChannelWithoutTailJudgement()
|
|
{
|
|
const string source = @"
|
|
#BPM 120
|
|
#WAV01 head.wav
|
|
#WAV02 tail.wav
|
|
#00151:0102
|
|
";
|
|
|
|
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("long.bms", source));
|
|
|
|
Assert.That(chart.Notes, Has.Count.EqualTo(1));
|
|
Assert.That(chart.Notes[0].IsLong, Is.True);
|
|
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
|
|
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.0).Within(0.000001));
|
|
}
|
|
|
|
[TestCase(120.0, 0.125)]
|
|
[TestCase(150.0, 0.100)]
|
|
[TestCase(180.0, 0.0833333333333333)]
|
|
public void LongNoteMusicalSixteenths_FollowTempo(double bpm, double expectedInterval)
|
|
{
|
|
var tempoMap = TempoMap.Constant(bpm);
|
|
var note = new PlayableNote
|
|
{
|
|
Id = 1,
|
|
Lane = 0,
|
|
StartTime = 0,
|
|
EndTime = tempoMap.BeatToTime(1.0),
|
|
StartBeat = 0,
|
|
EndBeat = 1.0,
|
|
};
|
|
|
|
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
|
|
|
|
Assert.That(ticks, Has.Length.EqualTo(4));
|
|
Assert.That(ticks[0], Is.EqualTo(expectedInterval).Within(0.000001));
|
|
for (var index = 1; index < ticks.Length; index++)
|
|
Assert.That(ticks[index] - ticks[index - 1],
|
|
Is.EqualTo(expectedInterval).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void Compiler_PreservesMeasureLengthTempoPriorityAndStopInTempoMap()
|
|
{
|
|
const string source = @"
|
|
#BPM 120
|
|
#BPM01 180
|
|
#STOP01 48
|
|
#00002:0.5
|
|
#00003:0078
|
|
#00008:0001
|
|
#00009:0001
|
|
#00011:0001
|
|
#00112:01
|
|
";
|
|
|
|
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("timing.bms", source));
|
|
var stopNote = chart.Notes.Single(note => note.Lane == 0);
|
|
var nextMeasureNote = chart.Notes.Single(note => note.Lane == 1);
|
|
|
|
Assert.That(stopNote.StartBeat, Is.EqualTo(1.0).Within(0.000001));
|
|
Assert.That(stopNote.StartTime, Is.EqualTo(0.5).Within(0.000001),
|
|
"Objects at a STOP position are judged before the STOP duration.");
|
|
Assert.That(nextMeasureNote.StartBeat, Is.EqualTo(2.0).Within(0.000001));
|
|
Assert.That(nextMeasureNote.StartTime, Is.EqualTo(7.0 / 6.0).Within(0.000001));
|
|
Assert.That(chart.TempoMap.Nodes.Count, Is.EqualTo(2));
|
|
Assert.That(chart.TempoMap.Nodes[1].Bpm, Is.EqualTo(180));
|
|
Assert.That(chart.TempoMap.Nodes[1].StopDuration, Is.EqualTo(1.0 / 3.0).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void Compiler_DerivesBpmRangeFromUsedTempoEventsOnly()
|
|
{
|
|
const string source = @"
|
|
#BPM 150
|
|
#BPM01 210
|
|
#BPM02 999
|
|
#SCROLL01 0.5
|
|
#00103:5A
|
|
#00208:01
|
|
#002SC:01
|
|
#00311:01
|
|
";
|
|
|
|
var compiled = BmsTimelineCompiler.Compile(
|
|
BmsParser.ParseText("tempo-range.bms", source));
|
|
var playable = BmsPlayableChartConverter.Convert(compiled).Chart;
|
|
|
|
Assert.That(compiled.Metadata.InitialBpm, Is.EqualTo(150));
|
|
Assert.That(compiled.Metadata.MinimumBpm, Is.EqualTo(90));
|
|
Assert.That(compiled.Metadata.MaximumBpm, Is.EqualTo(210));
|
|
Assert.That(playable.Metadata.InitialTempo, Is.EqualTo(150));
|
|
Assert.That(playable.Metadata.MinimumTempo, Is.EqualTo(90));
|
|
Assert.That(playable.Metadata.MaximumTempo, Is.EqualTo(210));
|
|
Assert.That(
|
|
compiled.Metadata.MaximumBpm,
|
|
Is.Not.EqualTo(999),
|
|
"An unused extended BPM definition must not enter the displayed range.");
|
|
}
|
|
|
|
[Test]
|
|
public void ScrollMap_HoldsPositionForStopWhileSongTimeAdvances()
|
|
{
|
|
var tempoMap = new TempoMap(new[]
|
|
{
|
|
new TempoMapNode(0, 0, 120),
|
|
new TempoMapNode(1, 0.5, 180, stopDuration: 1.0 / 3.0),
|
|
});
|
|
var scrollMap = new ScrollMap(tempoMap);
|
|
|
|
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1.0).Within(0.000001));
|
|
Assert.That(scrollMap.PositionAtTime(0.7), Is.EqualTo(1.0).Within(0.000001));
|
|
Assert.That(scrollMap.PositionAtTime(5.0 / 6.0), Is.EqualTo(1.0).Within(0.000001));
|
|
Assert.That(scrollMap.PositionAtTime(1.0), Is.EqualTo(1.5).Within(0.000001));
|
|
Assert.That(tempoMap.IsStopped(0.7), Is.True);
|
|
Assert.That(tempoMap.IsStopped(1.0), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void ParserAndTimeline_CompileBmsScrollWithoutChangingJudgementTime()
|
|
{
|
|
const string source = @"
|
|
#TITLE Scroll Test
|
|
#BPM 120
|
|
#SCROLL01 0.5
|
|
#SCROLL02 2.0
|
|
#001SC:0102
|
|
#00111:0101
|
|
";
|
|
|
|
var document = BmsParser.ParseText("scroll.bms", source);
|
|
var chart = BmsTimelineCompiler.Compile(document);
|
|
|
|
Assert.That(document.Scrolls["01"], Is.EqualTo(0.5));
|
|
Assert.That(document.Scrolls["02"], Is.EqualTo(2.0));
|
|
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
|
|
Assert.That(chart.Notes[1].StartTime, Is.EqualTo(3.0).Within(0.000001));
|
|
Assert.That(chart.ScrollMap.PositionAtBeat(4), Is.EqualTo(4).Within(0.000001));
|
|
Assert.That(chart.ScrollMap.PositionAtBeat(6), Is.EqualTo(5).Within(0.000001));
|
|
Assert.That(chart.ScrollMap.PositionAtBeat(8), Is.EqualTo(9).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void ScrollMap_TraversesZeroScrollBeforeFindingForwardVisibilityEnd()
|
|
{
|
|
var tempoMap = TempoMap.Constant(120);
|
|
var scrollMap = new ScrollMap(tempoMap, new[]
|
|
{
|
|
new ScrollMapNode(1, 0),
|
|
new ScrollMapNode(3, 1),
|
|
});
|
|
|
|
var found = scrollMap.TryGetTimeAtForwardDistance(
|
|
songTime: 0.25,
|
|
distance: 1.0,
|
|
out var visibilityEndTime);
|
|
|
|
Assert.That(found, Is.True);
|
|
Assert.That(visibilityEndTime, Is.EqualTo(1.75).Within(0.000001));
|
|
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1).Within(0.000001));
|
|
Assert.That(scrollMap.PositionAtTime(1.5), Is.EqualTo(1).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void Timeline_WarnsWhenScrollObjectIsUndefined()
|
|
{
|
|
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText(
|
|
"undefined-scroll.bms",
|
|
"#BPM 120\n#001SC:01\n#00111:01"));
|
|
|
|
Assert.That(chart.Warnings, Has.Some.Contains("Undefined SCROLL object 01"));
|
|
Assert.That(chart.ScrollMap.HasVariableScroll, Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void TempoMap_NegativePrerollIsNotShiftedByAStopAtBeatZero()
|
|
{
|
|
var tempoMap = new TempoMap(new[]
|
|
{
|
|
new TempoMapNode(0, 0, 120, stopDuration: 0.5),
|
|
});
|
|
|
|
Assert.That(tempoMap.TimeToBeat(-0.25), Is.EqualTo(-0.5).Within(0.000001));
|
|
Assert.That(tempoMap.TimeToBeat(0.25), Is.Zero.Within(0.000001));
|
|
Assert.That(tempoMap.TimeToBeat(0.75), Is.EqualTo(0.5).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void LongNoteMusicalTicks_CrossTempoChangeWithoutDuplication()
|
|
{
|
|
var tempoMap = new TempoMap(new[]
|
|
{
|
|
new TempoMapNode(0, 0, 120),
|
|
new TempoMapNode(1, 0.5, 180),
|
|
});
|
|
var note = new PlayableNote
|
|
{
|
|
Id = 1,
|
|
Lane = 0,
|
|
StartTime = 0,
|
|
EndTime = 5.0 / 6.0,
|
|
StartBeat = 0,
|
|
EndBeat = 2,
|
|
};
|
|
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
|
|
var engine = new RhythmJudgementEngine(new[] { note }, tempoMap: tempoMap);
|
|
|
|
engine.Press(0, 0);
|
|
engine.Update(note.EndTime);
|
|
|
|
Assert.That(ticks, Has.Length.EqualTo(8));
|
|
Assert.That(ticks[3], Is.EqualTo(0.5).Within(0.000001));
|
|
Assert.That(ticks[4] - ticks[3], Is.EqualTo(1.0 / 12.0).Within(0.000001));
|
|
Assert.That(engine.TotalScoringUnitCount, Is.EqualTo(9));
|
|
Assert.That(engine.Combo, Is.EqualTo(9));
|
|
Assert.That(engine.Score, Is.EqualTo(1_000_009));
|
|
}
|
|
|
|
[Test]
|
|
public void LongNoteReleasePolicy_ExposesEightyThreeAndOneHundredMillisecondBoundaries()
|
|
{
|
|
var strictCandidate = new LongNoteReleasePolicy(0.083333);
|
|
var djmaxCandidate = LongNoteReleasePolicy.DjmaxLike;
|
|
|
|
Assert.That(strictCandidate.Accepts(1.916667, 2.0), Is.True);
|
|
Assert.That(strictCandidate.Accepts(1.916665, 2.0), Is.False);
|
|
Assert.That(djmaxCandidate.Accepts(1.900000, 2.0), Is.True);
|
|
Assert.That(djmaxCandidate.Accepts(1.899998, 2.0), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public void Judgement_KoolBoundaryIsInclusive()
|
|
{
|
|
var profile = JudgementProfile.ProjectQuint;
|
|
|
|
Assert.That(profile.Grade(0.042), Is.EqualTo(JudgementGrade.Kool));
|
|
Assert.That(profile.Grade(0.042001), Is.EqualTo(JudgementGrade.Cool));
|
|
Assert.That(profile.Grade(0.083), Is.EqualTo(JudgementGrade.Cool));
|
|
Assert.That(profile.Grade(0.083001), Is.EqualTo(JudgementGrade.Good));
|
|
Assert.That(profile.Grade(0.125), Is.EqualTo(JudgementGrade.Good));
|
|
Assert.That(profile.Grade(0.125001), Is.EqualTo(JudgementGrade.Miss));
|
|
}
|
|
|
|
[Test]
|
|
public void JudgementBreakdown_CountsEveryFinalGradeExactlyOnce()
|
|
{
|
|
var notes = Enumerable.Range(1, 4)
|
|
.Select(index => new PlayableNote
|
|
{
|
|
Id = index,
|
|
Lane = 0,
|
|
StartTime = index,
|
|
EndTime = index,
|
|
})
|
|
.ToArray();
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
engine.Press(0, 1.0);
|
|
engine.Press(0, 2.05);
|
|
engine.Press(0, 3.10);
|
|
engine.Update(4.2);
|
|
|
|
Assert.That(engine.KoolCount, Is.EqualTo(1));
|
|
Assert.That(engine.CoolCount, Is.EqualTo(1));
|
|
Assert.That(engine.GoodCount, Is.EqualTo(1));
|
|
Assert.That(engine.MissCount, Is.EqualTo(1));
|
|
Assert.That(
|
|
engine.KoolCount + engine.CoolCount + engine.GoodCount + engine.MissCount,
|
|
Is.EqualTo(engine.JudgedNoteCount));
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(47.5).Within(0.000001));
|
|
Assert.That(engine.Score, Is.EqualTo(475001));
|
|
Assert.That(engine.Combo, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_EarlyReleaseBreaksComboButRemainsVisibleUntilTail()
|
|
{
|
|
var note = new PlayableNote { Id = 7, Lane = 2, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
var head = engine.Press(2, 1.0);
|
|
var release = engine.Release(2, 1.5);
|
|
var retry = engine.Press(2, 1.6);
|
|
|
|
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Kool));
|
|
Assert.That(release?.Grade, Is.EqualTo(JudgementGrade.Miss));
|
|
Assert.That(release?.LongNoteBreak, Is.True);
|
|
Assert.That(retry, Is.Null);
|
|
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.BrokenHolding));
|
|
Assert.That(engine.Combo, Is.Zero);
|
|
Assert.That(engine.Score, Is.EqualTo(545461));
|
|
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
|
|
Assert.That(engine.ResultSequence, Is.EqualTo(7),
|
|
"The head, five earned ticks, and long-note break must retrigger HUD feedback.");
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(600.0 / 11.0).Within(0.000001));
|
|
Assert.That(engine.KoolCount, Is.EqualTo(6));
|
|
Assert.That(engine.MissCount, Is.EqualTo(5));
|
|
Assert.That(engine.PurePerfectCount, Is.EqualTo(6));
|
|
Assert.That(engine.IsFinished, Is.False);
|
|
|
|
engine.Update(2.0);
|
|
|
|
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.Failed));
|
|
Assert.That(engine.IsFinished, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_MissedHeadRemainsUnfinishedUntilItsTail()
|
|
{
|
|
var note = new PlayableNote { Id = 8, Lane = 1, StartTime = 1.0, EndTime = 3.0, SoundId = "01" };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
engine.Update(1.2);
|
|
|
|
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.BrokenHolding),
|
|
"A missed long-note head must enter the dead-until-tail state, not the terminal failed state.");
|
|
Assert.That(engine.MissCount, Is.GreaterThan(0));
|
|
Assert.That(engine.IsFinished, Is.False,
|
|
"A head-missed long note must remain in the gameplay lifecycle until its tail.");
|
|
|
|
engine.Update(2.999);
|
|
Assert.That(engine.IsFinished, Is.False);
|
|
|
|
engine.Update(3.0);
|
|
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.Failed));
|
|
Assert.That(engine.IsFinished, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void Accuracy_UsesApprovedGradeWeightsAndOnlyJudgedNotes()
|
|
{
|
|
var notes = Enumerable.Range(0, 119)
|
|
.Select(index => new PlayableNote
|
|
{
|
|
Id = index,
|
|
Lane = 0,
|
|
StartTime = index,
|
|
EndTime = index,
|
|
SoundId = "01",
|
|
})
|
|
.ToArray();
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
|
|
for (var index = 0; index < 118; index++)
|
|
engine.Press(0, index);
|
|
engine.Press(0, 118.05);
|
|
|
|
Assert.That(engine.JudgedNoteCount, Is.EqualTo(119));
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(99.7478991597).Within(0.0000001));
|
|
}
|
|
|
|
[Test]
|
|
public void Score_NormalizesAccuracyToOneMillionAndAddsPurePerfectBonus()
|
|
{
|
|
var notes = Enumerable.Range(0, 16)
|
|
.Select(index => new PlayableNote
|
|
{
|
|
Id = index,
|
|
Lane = 0,
|
|
StartTime = index,
|
|
EndTime = index,
|
|
SoundId = "01",
|
|
})
|
|
.ToArray();
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
for (var index = 0; index < 15; index++)
|
|
engine.Press(0, index);
|
|
engine.Press(0, 15.05);
|
|
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(98.125).Within(0.000001));
|
|
Assert.That(engine.PurePerfectCount, Is.EqualTo(15));
|
|
Assert.That(engine.Score, Is.EqualTo(981265));
|
|
}
|
|
|
|
[Test]
|
|
public void CourseScore_SumsPerStageScoresAndKeepsAccuracyAcrossStageTransition()
|
|
{
|
|
var first = new RhythmJudgementEngine(new[]
|
|
{
|
|
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0 },
|
|
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.0 },
|
|
});
|
|
first.Press(0, 1.0);
|
|
first.Press(0, 2.05);
|
|
var progress = new CourseProgress();
|
|
progress.AddStage(first, cleared: true);
|
|
|
|
var second = new RhythmJudgementEngine(new[]
|
|
{
|
|
new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0 },
|
|
new PlayableNote { Id = 4, Lane = 0, StartTime = 2.0 },
|
|
});
|
|
|
|
Assert.That(progress.Score, Is.EqualTo(first.Score));
|
|
Assert.That(progress.StageResults.Count, Is.EqualTo(1));
|
|
Assert.That(
|
|
progress.CombinedAccuracyPercent(second),
|
|
Is.EqualTo(first.AccuracyPercent).Within(0.000001),
|
|
"An unjudged next stage must not make the cumulative HUD rate jump to 100%.");
|
|
Assert.That(progress.CombinedScore(second), Is.EqualTo(first.Score));
|
|
|
|
second.Press(0, 1.0);
|
|
second.Press(0, 2.0);
|
|
progress.AddStage(second, cleared: true);
|
|
|
|
Assert.That(progress.Score, Is.EqualTo(first.Score + second.Score));
|
|
Assert.That(progress.StageResults.Count, Is.EqualTo(2));
|
|
Assert.That(progress.StageResults[1].Score, Is.EqualTo(second.Score));
|
|
}
|
|
|
|
[Test]
|
|
public void PurePerfectTheoreticalScore_AddsOnePointPerNeutralTimedNote()
|
|
{
|
|
const int noteCount = 3200;
|
|
var notes = Enumerable.Range(0, noteCount)
|
|
.Select(index => new PlayableNote
|
|
{
|
|
Id = index,
|
|
Lane = index % 5,
|
|
StartTime = index * 0.01,
|
|
EndTime = index * 0.01,
|
|
})
|
|
.ToArray();
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
foreach (var note in notes)
|
|
engine.Press(note.Lane, note.StartTime);
|
|
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
|
|
Assert.That(engine.PurePerfectCount, Is.EqualTo(noteCount));
|
|
Assert.That(engine.Score, Is.EqualTo(1_003_200));
|
|
}
|
|
|
|
[Test]
|
|
public void TimingStats_CountSuccessfulPressesButExcludeAutomaticMisses()
|
|
{
|
|
var notes = new[]
|
|
{
|
|
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" },
|
|
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0, SoundId = "01" },
|
|
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0, SoundId = "01" },
|
|
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0, SoundId = "01" },
|
|
};
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
engine.Press(0, 0.98);
|
|
engine.Press(1, 2.03);
|
|
engine.Press(2, 3.0);
|
|
engine.Update(4.2);
|
|
|
|
Assert.That(engine.FastCount, Is.Zero);
|
|
Assert.That(engine.LateCount, Is.EqualTo(1));
|
|
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(1));
|
|
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.EqualTo(0.03).Within(0.000001));
|
|
Assert.That(engine.AverageTimingOffsetMilliseconds, Is.EqualTo(10.0 / 3.0).Within(0.000001));
|
|
Assert.That(engine.MissCount, Is.EqualTo(1));
|
|
}
|
|
|
|
[Test]
|
|
public void TimingFeedback_TreatsPlusOrMinusTwentyTwoMillisecondsAsNeutral()
|
|
{
|
|
var notes = new[]
|
|
{
|
|
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
|
|
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
|
|
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0 },
|
|
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0 },
|
|
};
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
engine.Press(0, 0.978);
|
|
engine.Press(1, 2.022);
|
|
engine.Press(2, 2.977999);
|
|
engine.Press(3, 4.022001);
|
|
|
|
Assert.That(engine.FastCount, Is.EqualTo(1));
|
|
Assert.That(engine.LateCount, Is.EqualTo(1));
|
|
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(2));
|
|
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.LessThan(-0.022));
|
|
Assert.That(engine.RecentTimingSamples[1].OffsetSeconds, Is.GreaterThan(0.022));
|
|
Assert.That(engine.TimingHistogram.Sum(), Is.EqualTo(4));
|
|
Assert.That(engine.PurePerfectCount, Is.EqualTo(2));
|
|
}
|
|
|
|
[Test]
|
|
public void JudgementOffset_ShiftsHitTimingButKeepsPhysicalFeedbackTime()
|
|
{
|
|
var note = new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
var result = engine.Press(0, 0.98, 0.02);
|
|
|
|
Assert.That(result?.Grade, Is.EqualTo(JudgementGrade.Kool));
|
|
Assert.That(result?.OffsetSeconds, Is.Zero.Within(0.000001));
|
|
Assert.That(engine.LastResultTime, Is.EqualTo(0.98).Within(0.000001));
|
|
Assert.That(engine.FastCount, Is.Zero);
|
|
Assert.That(engine.LateCount, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_HeldPastTailCompletesWithoutReleaseResult()
|
|
{
|
|
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
engine.Press(0, 1.0);
|
|
engine.Update(2.1);
|
|
var release = engine.Release(0, 2.2);
|
|
|
|
Assert.That(release, Is.Null);
|
|
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
|
|
Assert.That(engine.MissCount, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_ReleaseWithinDjmaxLikeOneHundredMillisecondsOfTailCompletes()
|
|
{
|
|
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
engine.Press(0, 1.0);
|
|
var release = engine.Release(0, 2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds);
|
|
|
|
Assert.That(release, Is.Null);
|
|
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
|
|
Assert.That(engine.Combo, Is.EqualTo(11));
|
|
Assert.That(engine.MissCount, Is.Zero);
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_ReleaseBeyondDjmaxLikeGraceStillFailsPermanently()
|
|
{
|
|
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
engine.Press(0, 1.0);
|
|
var release = engine.Release(
|
|
0,
|
|
2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds - 0.000002);
|
|
|
|
Assert.That(release?.LongNoteBreak, Is.True);
|
|
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.BrokenHolding));
|
|
Assert.That(engine.Combo, Is.Zero);
|
|
Assert.That(engine.MissCount, Is.EqualTo(2),
|
|
"The 900 ms and tail ticks remain unearned just beyond the 100 ms grace.");
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_HoldTicksIncreaseComboEveryOneHundredMilliseconds()
|
|
{
|
|
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.05 };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
var judgementResults = new List<JudgementResult>();
|
|
engine.Judged += judgementResults.Add;
|
|
|
|
engine.Press(0, 1.0);
|
|
Assert.That(engine.Score, Is.EqualTo(90910));
|
|
engine.Update(1.099);
|
|
Assert.That(engine.Combo, Is.EqualTo(1));
|
|
|
|
engine.Update(1.1);
|
|
Assert.That(engine.Combo, Is.EqualTo(2));
|
|
Assert.That(engine.Score, Is.EqualTo(181820));
|
|
|
|
engine.Update(1.49);
|
|
Assert.That(engine.Combo, Is.EqualTo(5));
|
|
|
|
engine.Update(2.05);
|
|
Assert.That(engine.Combo, Is.EqualTo(11));
|
|
Assert.That(engine.MaxCombo, Is.EqualTo(11));
|
|
Assert.That(engine.Score, Is.EqualTo(1_000_011));
|
|
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
|
|
Assert.That(engine.KoolCount, Is.EqualTo(11));
|
|
Assert.That(engine.PurePerfectCount, Is.EqualTo(11));
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
|
|
Assert.That(judgementResults, Has.Count.EqualTo(11));
|
|
Assert.That(judgementResults[0].IsHoldTick, Is.False);
|
|
Assert.That(judgementResults.Skip(1).All(result =>
|
|
result.IsHoldTick && result.Grade == JudgementGrade.Kool), Is.True,
|
|
"Every tick after a COOL-or-better head must pop a KOOL judgement.");
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_HoldTicksRaiseAccuracyAndScoreAfterAnImperfectHead()
|
|
{
|
|
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
engine.Press(0, 1.05);
|
|
var headScore = engine.Score;
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(70.0));
|
|
|
|
engine.Update(1.1);
|
|
|
|
Assert.That(engine.AccuracyPercent, Is.EqualTo(85.0));
|
|
Assert.That(engine.Score, Is.GreaterThan(headScore));
|
|
Assert.That(engine.PurePerfectCount, Is.EqualTo(1),
|
|
"A COOL head promotes each successful long-note tick to KOOL Pure Perfect.");
|
|
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Kool));
|
|
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_GoodHeadKeepsEverySuccessfulTickGood()
|
|
{
|
|
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
|
var engine = new RhythmJudgementEngine(new[] { note });
|
|
|
|
var head = engine.Press(0, 1.1);
|
|
engine.Update(1.1);
|
|
|
|
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Good));
|
|
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Good));
|
|
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
|
|
Assert.That(engine.GoodCount, Is.EqualTo(2));
|
|
Assert.That(engine.KoolCount, Is.Zero);
|
|
Assert.That(engine.PurePerfectCount, Is.Zero);
|
|
}
|
|
|
|
[Test]
|
|
public void Judgement_SelectsNearestPendingNoteWithinTheLaneWindow()
|
|
{
|
|
var notes = new[]
|
|
{
|
|
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
|
|
new PlayableNote { Id = 2, Lane = 0, StartTime = 1.1, EndTime = 1.1 },
|
|
};
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
var result = engine.Press(0, 1.08);
|
|
|
|
Assert.That(result?.NoteId, Is.EqualTo(2));
|
|
Assert.That(result?.OffsetSeconds, Is.EqualTo(-0.02).Within(0.000001));
|
|
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Pending));
|
|
}
|
|
|
|
[Test]
|
|
public void Judgement_ResultDoesNotDependOnUpdateCadence()
|
|
{
|
|
var notes = new[]
|
|
{
|
|
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
|
|
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
|
|
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 4.0 },
|
|
};
|
|
var frequentUpdates = new RhythmJudgementEngine(notes);
|
|
var sparseUpdates = new RhythmJudgementEngine(notes);
|
|
|
|
for (var time = 0.0; time < 1.8; time += 0.01)
|
|
frequentUpdates.Update(time);
|
|
frequentUpdates.Press(1, 2.0);
|
|
for (var time = 2.01; time < 2.9; time += 0.01)
|
|
frequentUpdates.Update(time);
|
|
frequentUpdates.Press(2, 3.0);
|
|
frequentUpdates.Release(2, 3.5);
|
|
for (var time = 3.51; time <= 4.5; time += 0.01)
|
|
frequentUpdates.Update(time);
|
|
|
|
sparseUpdates.Update(1.8);
|
|
sparseUpdates.Press(1, 2.0);
|
|
sparseUpdates.Update(2.9);
|
|
sparseUpdates.Press(2, 3.0);
|
|
sparseUpdates.Release(2, 3.5);
|
|
sparseUpdates.Update(4.5);
|
|
|
|
Assert.That(sparseUpdates.Score, Is.EqualTo(frequentUpdates.Score));
|
|
Assert.That(sparseUpdates.MissCount, Is.EqualTo(frequentUpdates.MissCount));
|
|
Assert.That(sparseUpdates.Combo, Is.EqualTo(frequentUpdates.Combo));
|
|
Assert.That(sparseUpdates.AccuracyPercent, Is.EqualTo(frequentUpdates.AccuracyPercent));
|
|
Assert.That(sparseUpdates.GetState(1), Is.EqualTo(frequentUpdates.GetState(1)));
|
|
Assert.That(sparseUpdates.GetState(2), Is.EqualTo(frequentUpdates.GetState(2)));
|
|
Assert.That(sparseUpdates.GetState(3), Is.EqualTo(frequentUpdates.GetState(3)));
|
|
Assert.That(sparseUpdates.IsFinished, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void LongNote_NextHeadCanBeJudgedAfterPreviousTailWithoutFrameUpdate()
|
|
{
|
|
var notes = new[]
|
|
{
|
|
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 2.0 },
|
|
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.2, EndTime = 3.0 },
|
|
};
|
|
var engine = new RhythmJudgementEngine(notes);
|
|
|
|
engine.Press(0, 1.0);
|
|
var secondHead = engine.Press(0, 2.2);
|
|
|
|
Assert.That(secondHead?.NoteId, Is.EqualTo(2));
|
|
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Completed));
|
|
Assert.That(engine.GetState(2), Is.EqualTo(RuntimeNoteState.Holding));
|
|
}
|
|
|
|
[Test]
|
|
public void SongClock_UsesDspNormallyAndFallsBackOnlyAfterARealStall()
|
|
{
|
|
var clock = new ResilientSongClock();
|
|
clock.Reset(currentDspTime: 100.0, startDspTime: 100.0, currentRealtime: 0.0);
|
|
|
|
Assert.That(clock.Sample(100.1, 100.0, 0.1), Is.EqualTo(0.1).Within(0.000001));
|
|
Assert.That(clock.IsFallbackActive, Is.False);
|
|
Assert.That(clock.Sample(100.1, 100.0, 0.2), Is.EqualTo(0.1).Within(0.000001));
|
|
Assert.That(clock.IsFallbackActive, Is.False);
|
|
Assert.That(clock.Sample(100.1, 100.0, 0.4), Is.EqualTo(0.4).Within(0.000001));
|
|
Assert.That(clock.IsFallbackActive, Is.True);
|
|
Assert.That(clock.Sample(100.1, 100.0, 0.5), Is.EqualTo(0.5).Within(0.000001));
|
|
}
|
|
|
|
[Test]
|
|
public void SongClock_MapsInputEventRelativeToTheCurrentInputClock()
|
|
{
|
|
var mapped = ResilientSongClock.ResolveInputEventSongTime(
|
|
inputEventTime: 10.995,
|
|
currentInputTime: 11.000,
|
|
currentSongTime: 2.000);
|
|
|
|
Assert.That(mapped, Is.EqualTo(1.995).Within(0.000001));
|
|
}
|
|
}
|
|
}
|