fix: retail text and golden-string tests must not follow the machine's locale
Run 170's Windows gate went red on 37 tests across four assemblies while the same commit passed 14,370/0 locally. The failures were all one family: Expected: "You have 1 500p" <- built with the machine's culture Actual: "You have 1,500p" <- production, correctly invariant The runner is Swedish; this dev box is not. These tests had been passing on CI only because that machine's registry locale had been pinned by hand — machine state, which came undone (almost certainly the reboot after today's hang). Re-pinning it would be a workaround on one machine for a defect in the repo, so this fixes the repo instead. Two genuinely different bugs were hiding in that one symptom. 1. TESTS that build an expected string with the ambient culture and compare it to invariant production output, and test-side recording sinks whose traces are compared against literal golden strings. Those only ever passed on a machine that happens to format like the invariant culture. Pinned to InvariantCulture: the vendor purse/cost expectations, and the motion-funnel, animation-sequencer, framebuffer-resize, resource-slot, and runtime-attack trace sinks. 2. PRODUCTION that formats player-visible retail text with the ambient culture. This one matters beyond CI: retail is a US client, so it shows "2.50", "1,500p" and "(-20)" to everyone. On a Swedish machine acdream was showing "2,50", "1 500p" and "(-20)" with U+2212 MINUS SIGN — the audience for this alpha is literally Swedish. Converted 76 sites to InvariantCulture across the item/creature appraisal formatters, the character stat panel's buff and vitae parentheticals, the appraisal and link-status controllers, the chat /framerate and /location output, the camera sensitivity toast, the time-override toast, the F3 dump, the sky diagnostics, and the world-frame invariant-failure message. DATES are deliberately left on the current culture (CharacterController's birth/login stamp, RuntimeHouseState's purchase expiry). Retail has no answer for a non-US player's date format, and forcing "08/19/2026 7:00:00 PM" on them is a UX decision, not a retail-fidelity one. Apparatus, so the next occurrence is reproducible instead of mysterious: tests/TestCultureInitializer.cs adds an opt-in ACDREAM_TEST_CULTURE knob to every test assembly, linked in through a new tests/Directory.Build.props. Unset — what CI and everyone runs — it changes nothing. ACDREAM_TEST_CULTURE=sv-SE dotnet test ... reproduced all 37 CI failures on this machine plus 6 more the runner's own locale does not surface (the Unicode-minus family), and drove the fix. Verified both ways on the full solution under the release-gate filter: default culture 14,370 passed / 0 failed, and ACDREAM_TEST_CULTURE=sv-SE 14,370 passed / 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
6e6e6f8de4
commit
6a15dd063c
20 changed files with 271 additions and 112 deletions
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
|
|
@ -233,13 +234,17 @@ internal sealed class NearbyWorldDiagnosticDumper
|
|||
int landblockX = centerX + (int)MathF.Floor(position.X / 192f);
|
||||
int landblockY = centerY + (int)MathF.Floor(position.Y / 192f);
|
||||
|
||||
_log(
|
||||
// Invariant: a diagnostic dump is pasted into issues and compared
|
||||
// against other machines' dumps, so its numbers must not change shape
|
||||
// with the reporter's locale.
|
||||
_log(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"=== F3 DEBUG DUMP ===\n" +
|
||||
$" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" +
|
||||
$" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " +
|
||||
$"local=({position.X - (landblockX - centerX) * 192f:F2}," +
|
||||
$"{position.Y - (landblockY - centerY) * 192f:F2})\n" +
|
||||
$" total shadow objects: {_source.TotalShadowObjects}");
|
||||
$" total shadow objects: {_source.TotalShadowObjects}"));
|
||||
|
||||
List<WorldEntity> visibleNearby = _source.WorldEntities
|
||||
.Where(entity => HorizontalDistanceSquared(entity.Position, position)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Rendering;
|
||||
|
|
@ -343,7 +344,9 @@ internal sealed class CameraPointerInputController
|
|||
else
|
||||
_orbitSensitivity = next;
|
||||
|
||||
return $"{mode} sens {next:F3}x";
|
||||
// Invariant: player-visible toast; a decimal-comma culture would
|
||||
// otherwise render "Orbit sens 1,200x".
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{mode} sens {next:F3}x");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
SetText(
|
||||
CreatureLevelValueId,
|
||||
level > 0
|
||||
? level.ToString(CultureInfo.CurrentCulture)
|
||||
? level.ToString(CultureInfo.InvariantCulture)
|
||||
: "???");
|
||||
|
||||
if (character)
|
||||
|
|
@ -933,7 +933,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
|
|||
if (string.IsNullOrWhiteSpace(name))
|
||||
name = _itemNames.ResolveAppropriateName(obj);
|
||||
return obj.StackSize > 1
|
||||
? $"{obj.StackSize.ToString(CultureInfo.CurrentCulture)} {name}"
|
||||
? $"{obj.StackSize.ToString(CultureInfo.InvariantCulture)} {name}"
|
||||
: name;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ public static class CharacterController
|
|||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
return unixSeconds.ToString(CultureInfo.CurrentCulture);
|
||||
return unixSeconds.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -308,7 +308,7 @@ public static class CharacterController
|
|||
: NumericValue(values[Math.Min(i, values.Length - 1)]);
|
||||
body.Append(ResolvePlural(tokens[i], pluralValue));
|
||||
if (i < values.Length)
|
||||
body.Append(Convert.ToString(values[i], CultureInfo.CurrentCulture));
|
||||
body.Append(Convert.ToString(values[i], CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
|
|
@ -1369,11 +1370,15 @@ public static class CharacterStatController
|
|||
/// prefix on an increase (the natural %d has no leading plus), the
|
||||
/// natural minus sign on a decrease (no prefix string set in that
|
||||
/// branch), nothing when the delta is zero.</summary>
|
||||
/// <remarks>Invariant, not the machine's culture: retail's minus is the
|
||||
/// ASCII '-', and several European cultures (sv-SE among them) render a
|
||||
/// negative integer with U+2212 MINUS SIGN, so a Swedish player would
|
||||
/// otherwise read "(−20)" where retail shows "(-20)".</remarks>
|
||||
private static string FormatBuffDelta(int delta) => delta switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
> 0 => $" (+{delta})",
|
||||
_ => $" ({delta})",
|
||||
> 0 => string.Create(CultureInfo.InvariantCulture, $" (+{delta})"),
|
||||
_ => string.Create(CultureInfo.InvariantCulture, $" ({delta})"),
|
||||
};
|
||||
|
||||
/// <summary>Retail " (%d)" vitae-specific parenthetical
|
||||
|
|
@ -1381,7 +1386,9 @@ public static class CharacterStatController
|
|||
/// while vitae is an active penalty (modifier < 0) — vitae never
|
||||
/// grants a bonus, so no "+" case exists.</summary>
|
||||
private static string FormatVitaeDelta(int vitaeModifier) =>
|
||||
vitaeModifier < 0 ? $" ({vitaeModifier})" : string.Empty;
|
||||
vitaeModifier < 0
|
||||
? string.Create(CultureInfo.InvariantCulture, $" ({vitaeModifier})")
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>Build the retail footer-title text for the current selection:
|
||||
/// "{Name}: {value}" with the vitae + buff-delta parentheticals appended
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ public static class CreatureAppraisalRows
|
|||
properties.Ints.TryGetValue(id, out int value) ? value : 0;
|
||||
|
||||
private static string Number(int value) =>
|
||||
value.ToString(CultureInfo.CurrentCulture);
|
||||
value.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
private static CreatureAppraisalRow Primary(
|
||||
string label,
|
||||
|
|
@ -163,7 +163,7 @@ public static class CreatureAppraisalRows
|
|||
=> new(
|
||||
label,
|
||||
value is > 0
|
||||
? value.Value.ToString(CultureInfo.CurrentCulture)
|
||||
? value.Value.ToString(CultureInfo.InvariantCulture)
|
||||
: Unknown,
|
||||
Style(enchantmentBit, profile, success));
|
||||
|
||||
|
|
@ -183,12 +183,12 @@ public static class CreatureAppraisalRows
|
|||
if (success)
|
||||
{
|
||||
value = showPercent
|
||||
? $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)} ({percent.ToString(CultureInfo.CurrentCulture)} %)"
|
||||
: $"{current.Value.ToString(CultureInfo.CurrentCulture)}/{maximum.Value.ToString(CultureInfo.CurrentCulture)}";
|
||||
? $"{current.Value.ToString(CultureInfo.InvariantCulture)}/{maximum.Value.ToString(CultureInfo.InvariantCulture)} ({percent.ToString(CultureInfo.InvariantCulture)} %)"
|
||||
: $"{current.Value.ToString(CultureInfo.InvariantCulture)}/{maximum.Value.ToString(CultureInfo.InvariantCulture)}";
|
||||
}
|
||||
else if (showPercent)
|
||||
{
|
||||
value = $"{percent.ToString(CultureInfo.CurrentCulture)} %";
|
||||
value = $"{percent.ToString(CultureInfo.InvariantCulture)} %";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ public static class ItemAppraisalTextFormatter
|
|||
if (properties.Ints.TryGetValue(171u, out int tinkers))
|
||||
{
|
||||
report.Line(
|
||||
$"This item has been tinkered {tinkers.ToString(CultureInfo.CurrentCulture)} "
|
||||
$"This item has been tinkered {tinkers.ToString(CultureInfo.InvariantCulture)} "
|
||||
+ (tinkers == 1 ? "time." : "times."));
|
||||
}
|
||||
|
||||
|
|
@ -124,15 +124,15 @@ public static class ItemAppraisalTextFormatter
|
|||
10);
|
||||
report.Line(
|
||||
$"Workmanship: {WorkmanshipAdjective(workmanshipBand)} "
|
||||
+ $"({average.ToString("0.00", CultureInfo.CurrentCulture)})");
|
||||
+ $"({average.ToString("0.00", CultureInfo.InvariantCulture)})");
|
||||
report.Paragraph(
|
||||
$"Salvaged from {salvagedItems.ToString(CultureInfo.CurrentCulture)} items.");
|
||||
$"Salvaged from {salvagedItems.ToString(CultureInfo.InvariantCulture)} items.");
|
||||
}
|
||||
else
|
||||
{
|
||||
report.Line(
|
||||
$"Workmanship: {WorkmanshipAdjective(Math.Clamp(workmanship, 0, 10))} "
|
||||
+ $"({workmanship.ToString(CultureInfo.CurrentCulture)})");
|
||||
+ $"({workmanship.ToString(CultureInfo.InvariantCulture)})");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +172,7 @@ public static class ItemAppraisalTextFormatter
|
|||
.Where(pair => properties.Ints.TryGetValue(pair.Property, out int value)
|
||||
&& value > 0)
|
||||
.Select(pair =>
|
||||
$"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.CurrentCulture)}")
|
||||
$"{pair.Label} {properties.GetInt(pair.Property).ToString(CultureInfo.InvariantCulture)}")
|
||||
.ToArray();
|
||||
bool ratingsShown = ratings.Length != 0;
|
||||
if (ratingsShown)
|
||||
|
|
@ -180,7 +180,7 @@ public static class ItemAppraisalTextFormatter
|
|||
if (properties.GetInt(379u) is int vitality && vitality > 0)
|
||||
{
|
||||
report.Line(
|
||||
$"This item adds {vitality.ToString(CultureInfo.CurrentCulture)} Vitality.");
|
||||
$"This item adds {vitality.ToString(CultureInfo.InvariantCulture)} Vitality.");
|
||||
ratingsShown = true;
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +218,7 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
if (properties.Ints.TryGetValue(28u, out int shieldLevel))
|
||||
report.Line(
|
||||
$"Base Shield Level: {shieldLevel.ToString(CultureInfo.CurrentCulture)}",
|
||||
$"Base Shield Level: {shieldLevel.ToString(CultureInfo.InvariantCulture)}",
|
||||
EnchantmentStyle(
|
||||
appraisal.ArmorEnchantments,
|
||||
0x0001u));
|
||||
|
|
@ -248,8 +248,8 @@ public static class ItemAppraisalTextFormatter
|
|||
(1d - weapon.DamageVariance) * weapon.Damage;
|
||||
damage = weapon.Damage - minimumDamage > 0.0002d
|
||||
? $"{FormatRetailDamage(minimumDamage)}"
|
||||
+ $" - {weapon.Damage.ToString(CultureInfo.CurrentCulture)}"
|
||||
: weapon.Damage.ToString(CultureInfo.CurrentCulture);
|
||||
+ $" - {weapon.Damage.ToString(CultureInfo.InvariantCulture)}"
|
||||
: weapon.Damage.ToString(CultureInfo.InvariantCulture);
|
||||
if (!launcher)
|
||||
{
|
||||
damage += TryDamageTypeName(weapon.DamageType, out string? type)
|
||||
|
|
@ -272,7 +272,7 @@ public static class ItemAppraisalTextFormatter
|
|||
if (elementalBonus > 0)
|
||||
report.Line(
|
||||
$"Elemental Damage Bonus: "
|
||||
+ $"{elementalBonus.ToString(CultureInfo.CurrentCulture)}, "
|
||||
+ $"{elementalBonus.ToString(CultureInfo.InvariantCulture)}, "
|
||||
+ $"{DamageTypeName(weapon.DamageType)}.");
|
||||
|
||||
if (launcher)
|
||||
|
|
@ -296,7 +296,7 @@ public static class ItemAppraisalTextFormatter
|
|||
weapon.WeaponTime == uint.MaxValue
|
||||
? "Speed: Unknown"
|
||||
: $"Speed: {WeaponTimeName((int)weapon.WeaponTime)} "
|
||||
+ $"({weapon.WeaponTime.ToString(CultureInfo.CurrentCulture)})",
|
||||
+ $"({weapon.WeaponTime.ToString(CultureInfo.InvariantCulture)})",
|
||||
EnchantmentStyle(
|
||||
appraisal.WeaponEnchantments,
|
||||
0x0004u));
|
||||
|
|
@ -321,7 +321,7 @@ public static class ItemAppraisalTextFormatter
|
|||
? (int)Math.Ceiling(rawRange)
|
||||
: (int)rawRange - (int)rawRange % 5;
|
||||
report.Line(
|
||||
$"Range: {range.ToString(CultureInfo.CurrentCulture)} yds."
|
||||
$"Range: {range.ToString(CultureInfo.InvariantCulture)} yds."
|
||||
+ (weapon.MaxVelocityEstimated != 0u
|
||||
? " (based on STRENGTH 100)"
|
||||
: string.Empty));
|
||||
|
|
@ -362,7 +362,7 @@ public static class ItemAppraisalTextFormatter
|
|||
// Retail's first armor string begins with a literal '\n' before
|
||||
// AddItemInfo adds its ordinary separator.
|
||||
report.Paragraph(
|
||||
$"Armor Level: {armorLevel.ToString(CultureInfo.CurrentCulture)}",
|
||||
$"Armor Level: {armorLevel.ToString(CultureInfo.InvariantCulture)}",
|
||||
EnchantmentStyle(appraisal.ArmorEnchantments, 0x0001u));
|
||||
ShowProtection(
|
||||
report, "Slashing", armorLevel, armor.SlashingProtection,
|
||||
|
|
@ -443,7 +443,7 @@ public static class ItemAppraisalTextFormatter
|
|||
double effective = armorLevel * modifier;
|
||||
report.Line(
|
||||
$"{damageType}: {quality} "
|
||||
+ $"({effective.ToString("0", CultureInfo.CurrentCulture)})",
|
||||
+ $"({effective.ToString("0", CultureInfo.InvariantCulture)})",
|
||||
style);
|
||||
}
|
||||
|
||||
|
|
@ -503,7 +503,7 @@ public static class ItemAppraisalTextFormatter
|
|||
ordinary.Select(raw =>
|
||||
{
|
||||
return resolveSpell(raw)?.Name
|
||||
?? $"Spell {raw.ToString(CultureInfo.CurrentCulture)}";
|
||||
?? $"Spell {raw.ToString(CultureInfo.InvariantCulture)}";
|
||||
}));
|
||||
report.Paragraph($"Spells: {names}");
|
||||
}
|
||||
|
|
@ -523,7 +523,7 @@ public static class ItemAppraisalTextFormatter
|
|||
report.BlankLine();
|
||||
report.Line(
|
||||
$"You can only carry "
|
||||
+ $"{carryLimit.ToString("N0", CultureInfo.CurrentCulture)} "
|
||||
+ $"{carryLimit.ToString("N0", CultureInfo.InvariantCulture)} "
|
||||
+ "of these items.");
|
||||
}
|
||||
|
||||
|
|
@ -539,7 +539,7 @@ public static class ItemAppraisalTextFormatter
|
|||
if (cleave > 1)
|
||||
{
|
||||
report.Line(
|
||||
$"Cleave: {cleave.ToString(CultureInfo.CurrentCulture)} enemies in front arc.");
|
||||
$"Cleave: {cleave.ToString(CultureInfo.InvariantCulture)} enemies in front arc.");
|
||||
report.BlankLine();
|
||||
}
|
||||
|
||||
|
|
@ -663,19 +663,19 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
report.Paragraph(minimum == maximum
|
||||
? $"Restricted to characters of Level "
|
||||
+ $"{minimum.ToString(CultureInfo.CurrentCulture)}."
|
||||
+ $"{minimum.ToString(CultureInfo.InvariantCulture)}."
|
||||
: $"Restricted to characters of Levels "
|
||||
+ $"{minimum.ToString(CultureInfo.CurrentCulture)} to "
|
||||
+ $"{maximum.ToString(CultureInfo.CurrentCulture)}.");
|
||||
+ $"{minimum.ToString(CultureInfo.InvariantCulture)} to "
|
||||
+ $"{maximum.ToString(CultureInfo.InvariantCulture)}.");
|
||||
}
|
||||
else if (minimum > 0)
|
||||
report.Paragraph(
|
||||
$"Restricted to characters of Level "
|
||||
+ $"{minimum.ToString(CultureInfo.CurrentCulture)} or greater.");
|
||||
+ $"{minimum.ToString(CultureInfo.InvariantCulture)} or greater.");
|
||||
else if (maximum > 0)
|
||||
report.Paragraph(
|
||||
$"Restricted to characters of Level "
|
||||
+ $"{maximum.ToString(CultureInfo.CurrentCulture)} or below.");
|
||||
+ $"{maximum.ToString(CultureInfo.InvariantCulture)} or below.");
|
||||
|
||||
string destination = properties.GetString(38u);
|
||||
if (!string.IsNullOrWhiteSpace(destination))
|
||||
|
|
@ -741,7 +741,7 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
report.Line(
|
||||
$"Wield requires {quality} "
|
||||
+ $"{difficulty.ToString(CultureInfo.CurrentCulture)}");
|
||||
+ $"{difficulty.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -790,11 +790,11 @@ public static class ItemAppraisalTextFormatter
|
|||
|
||||
if (level > 0)
|
||||
report.Line(
|
||||
$"Use requires level {level.ToString(CultureInfo.CurrentCulture)}.");
|
||||
$"Use requires level {level.ToString(CultureInfo.InvariantCulture)}.");
|
||||
if (skill > 0 && difficulty > 0)
|
||||
report.Line(
|
||||
$"Use requires {UsageSkillName(skill)} of at least "
|
||||
+ $"{difficulty.ToString(CultureInfo.CurrentCulture)}.");
|
||||
+ $"{difficulty.ToString(CultureInfo.InvariantCulture)}.");
|
||||
if (specializedSkill > 0)
|
||||
report.Line(
|
||||
$"Use requires specialized {UsageSkillName(specializedSkill)}.");
|
||||
|
|
@ -829,11 +829,11 @@ public static class ItemAppraisalTextFormatter
|
|||
style);
|
||||
|
||||
report.Line(
|
||||
$"Item Level: {displayedLevel.ToString(CultureInfo.CurrentCulture)} / "
|
||||
+ $"{maximum.ToString(CultureInfo.CurrentCulture)}");
|
||||
$"Item Level: {displayedLevel.ToString(CultureInfo.InvariantCulture)} / "
|
||||
+ $"{maximum.ToString(CultureInfo.InvariantCulture)}");
|
||||
report.Line(
|
||||
$"Item XP: {experience.ToString("N0", CultureInfo.CurrentCulture)} / "
|
||||
+ $"{nextExperience.ToString("N0", CultureInfo.CurrentCulture)}");
|
||||
$"Item XP: {experience.ToString("N0", CultureInfo.InvariantCulture)} / "
|
||||
+ $"{nextExperience.ToString("N0", CultureInfo.InvariantCulture)}");
|
||||
report.BlankLine();
|
||||
|
||||
if (properties.GetInt(352u) == 2)
|
||||
|
|
@ -871,19 +871,19 @@ public static class ItemAppraisalTextFormatter
|
|||
int skill = properties.GetInt(176u);
|
||||
if (skillLevel > 0 && skill > 0)
|
||||
requirements.Add(
|
||||
$"{UsageSkillName(skill)}: {skillLevel.ToString(CultureInfo.CurrentCulture)}");
|
||||
$"{UsageSkillName(skill)}: {skillLevel.ToString(CultureInfo.InvariantCulture)}");
|
||||
int attributeLevel = properties.GetInt(258u);
|
||||
int attribute = properties.GetInt(257u);
|
||||
if (attributeLevel > 0 && attribute > 0)
|
||||
requirements.Add(
|
||||
$"{PrimaryAttributeName(attribute)}: "
|
||||
+ $"{attributeLevel.ToString(CultureInfo.CurrentCulture)}");
|
||||
+ $"{attributeLevel.ToString(CultureInfo.InvariantCulture)}");
|
||||
int secondaryLevel = properties.GetInt(260u);
|
||||
int secondary = properties.GetInt(259u);
|
||||
if (secondaryLevel > 0 && secondary > 0)
|
||||
requirements.Add(
|
||||
$"{SecondaryAttributeName(secondary)}: "
|
||||
+ $"{secondaryLevel.ToString(CultureInfo.CurrentCulture)}");
|
||||
+ $"{secondaryLevel.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
if (requirements.Count != 0)
|
||||
report.Line($"Activation requires {string.Join(", ", requirements)}");
|
||||
|
|
@ -903,7 +903,7 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
if (value > 0)
|
||||
requirements.Add(
|
||||
$"{name}: {value.ToString(CultureInfo.CurrentCulture)}");
|
||||
$"{name}: {value.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
/// <summary><c>Appraisal_ShowCasterData @ 0x004B1B10</c>.</summary>
|
||||
|
|
@ -955,13 +955,13 @@ public static class ItemAppraisalTextFormatter
|
|||
string? boostText = properties.GetInt(89u) switch
|
||||
{
|
||||
2 => $"{(boost >= 0 ? "Restores" : "Depletes")} "
|
||||
+ $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} "
|
||||
+ $"{Math.Abs(boost).ToString(CultureInfo.InvariantCulture)} "
|
||||
+ "Health when used.",
|
||||
4 => $"{(boost >= 0 ? "Restores" : "Depletes")} "
|
||||
+ $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} "
|
||||
+ $"{Math.Abs(boost).ToString(CultureInfo.InvariantCulture)} "
|
||||
+ "Stamina when consumed.",
|
||||
6 => $"{(boost >= 0 ? "Restores" : "Depletes")} "
|
||||
+ $"{Math.Abs(boost).ToString(CultureInfo.CurrentCulture)} "
|
||||
+ $"{Math.Abs(boost).ToString(CultureInfo.InvariantCulture)} "
|
||||
+ "Mana when used.",
|
||||
_ => null,
|
||||
};
|
||||
|
|
@ -976,11 +976,11 @@ public static class ItemAppraisalTextFormatter
|
|||
|
||||
if (properties.Ints.TryGetValue(90u, out int healingBonus))
|
||||
report.Paragraph(
|
||||
$"Bonus to Healing Skill: {healingBonus.ToString(CultureInfo.CurrentCulture)}");
|
||||
$"Bonus to Healing Skill: {healingBonus.ToString(CultureInfo.InvariantCulture)}");
|
||||
if (properties.Floats.TryGetValue(100u, out double healKitModifier))
|
||||
report.Line(
|
||||
$"Restoration Bonus: "
|
||||
+ $"{(healKitModifier * 100d).ToString("0", CultureInfo.CurrentCulture)}%");
|
||||
+ $"{(healKitModifier * 100d).ToString("0", CultureInfo.InvariantCulture)}%");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1002,21 +1002,21 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
if (obj.ItemsCapacity > 0 && obj.ContainersCapacity > 0)
|
||||
report.Paragraph(
|
||||
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} "
|
||||
+ $"items and {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers.");
|
||||
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.InvariantCulture)} "
|
||||
+ $"items and {obj.ContainersCapacity.ToString(CultureInfo.InvariantCulture)} containers.");
|
||||
else if (obj.ItemsCapacity > 0)
|
||||
report.Paragraph(
|
||||
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.CurrentCulture)} items.");
|
||||
$"Can hold up to {obj.ItemsCapacity.ToString(CultureInfo.InvariantCulture)} items.");
|
||||
else if (obj.ContainersCapacity > 0)
|
||||
report.Paragraph(
|
||||
$"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.CurrentCulture)} containers.");
|
||||
$"Can hold up to {obj.ContainersCapacity.ToString(CultureInfo.InvariantCulture)} containers.");
|
||||
|
||||
int pages = properties.GetInt(175u);
|
||||
int pagesUsed = properties.GetInt(174u);
|
||||
if (pages > 0)
|
||||
report.Paragraph(
|
||||
$"{pagesUsed.ToString(CultureInfo.CurrentCulture)} of "
|
||||
+ $"{pages.ToString(CultureInfo.CurrentCulture)} pages full.");
|
||||
$"{pagesUsed.ToString(CultureInfo.InvariantCulture)} of "
|
||||
+ $"{pages.ToString(CultureInfo.InvariantCulture)} pages full.");
|
||||
}
|
||||
|
||||
// Appraisal_ShowLockAppraiseInfo @ 0x004B2790 does not present lock
|
||||
|
|
@ -1034,7 +1034,7 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
report.Paragraph(
|
||||
$"Bonus to Lockpick Skill: "
|
||||
+ $"{resistance.ToString("+0;-0;0", CultureInfo.CurrentCulture)}");
|
||||
+ $"{resistance.ToString("+0;-0;0", CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -1057,7 +1057,7 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
report.Paragraph(
|
||||
$"The lock looks {difficulty} to pick "
|
||||
+ $"(Resistance {resistance.ToString(CultureInfo.CurrentCulture)}).");
|
||||
+ $"(Resistance {resistance.ToString(CultureInfo.InvariantCulture)}).");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1071,14 +1071,14 @@ public static class ItemAppraisalTextFormatter
|
|||
PropertyBundle properties = appraisal.Properties;
|
||||
if (properties.Ints.TryGetValue(107u, out int storedMana))
|
||||
report.Line(
|
||||
$"Stored Mana: {storedMana.ToString(CultureInfo.CurrentCulture)}");
|
||||
$"Stored Mana: {storedMana.ToString(CultureInfo.InvariantCulture)}");
|
||||
if (properties.Floats.TryGetValue(87u, out double efficiency))
|
||||
report.Line(
|
||||
$"Efficiency: {(efficiency * 100d).ToString("0", CultureInfo.CurrentCulture)}%");
|
||||
$"Efficiency: {(efficiency * 100d).ToString("0", CultureInfo.InvariantCulture)}%");
|
||||
if (properties.Floats.TryGetValue(137u, out double destruction))
|
||||
report.Line(
|
||||
$"Chance of Destruction: "
|
||||
+ $"{(destruction * 100d).ToString("0", CultureInfo.CurrentCulture)}%");
|
||||
+ $"{(destruction * 100d).ToString("0", CultureInfo.InvariantCulture)}%");
|
||||
}
|
||||
|
||||
/// <summary><c>Appraisal_ShowRemainingUses @ 0x004B3A20</c>.</summary>
|
||||
|
|
@ -1090,7 +1090,7 @@ public static class ItemAppraisalTextFormatter
|
|||
PropertyBundle properties = appraisal.Properties;
|
||||
if (properties.Ints.TryGetValue(193u, out int keys))
|
||||
report.Line(
|
||||
$"Contains {keys.ToString(CultureInfo.CurrentCulture)} "
|
||||
$"Contains {keys.ToString(CultureInfo.InvariantCulture)} "
|
||||
+ (keys == 1 ? "key." : "keys."));
|
||||
|
||||
if (properties.GetBool(63u))
|
||||
|
|
@ -1102,7 +1102,7 @@ public static class ItemAppraisalTextFormatter
|
|||
if (properties.Ints.TryGetValue(92u, out int uses))
|
||||
{
|
||||
report.Line(
|
||||
$"Number of uses remaining: {uses.ToString(CultureInfo.CurrentCulture)}");
|
||||
$"Number of uses remaining: {uses.ToString(CultureInfo.InvariantCulture)}");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1144,7 +1144,7 @@ public static class ItemAppraisalTextFormatter
|
|||
}
|
||||
int rare = properties.GetInt(17u);
|
||||
if (rare > 0)
|
||||
report.Paragraph($"Rare #{rare.ToString(CultureInfo.CurrentCulture)}");
|
||||
report.Paragraph($"Rare #{rare.ToString(CultureInfo.InvariantCulture)}");
|
||||
}
|
||||
|
||||
/// <summary><c>Appraisal_ShowMagicInfo @ 0x004B2E10</c>.</summary>
|
||||
|
|
@ -1178,25 +1178,25 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
if (properties.Ints.TryGetValue(106u, out int spellcraft))
|
||||
report.Line(
|
||||
$"Spellcraft: {spellcraft.ToString(CultureInfo.CurrentCulture)}.");
|
||||
$"Spellcraft: {spellcraft.ToString(CultureInfo.InvariantCulture)}.");
|
||||
if (properties.Ints.TryGetValue(107u, out int currentMana)
|
||||
&& properties.Ints.TryGetValue(108u, out int maximumMana))
|
||||
report.Line(
|
||||
$"Mana: {currentMana.ToString(CultureInfo.CurrentCulture)} / "
|
||||
+ $"{maximumMana.ToString(CultureInfo.CurrentCulture)}.");
|
||||
$"Mana: {currentMana.ToString(CultureInfo.InvariantCulture)} / "
|
||||
+ $"{maximumMana.ToString(CultureInfo.InvariantCulture)}.");
|
||||
|
||||
if (properties.Floats.TryGetValue(5u, out double manaRate)
|
||||
&& Math.Abs(manaRate) > 0.000001d)
|
||||
{
|
||||
int seconds = (int)Math.Round(1d / manaRate);
|
||||
report.Line(
|
||||
$"Mana Cost: 1 point per {seconds.ToString(CultureInfo.CurrentCulture)} "
|
||||
$"Mana Cost: 1 point per {seconds.ToString(CultureInfo.InvariantCulture)} "
|
||||
+ (seconds == 1 ? "second." : "seconds."));
|
||||
}
|
||||
else if (properties.Ints.TryGetValue(117u, out int manaCost))
|
||||
{
|
||||
string manaCostText =
|
||||
$"Mana Cost: {manaCost.ToString(CultureInfo.CurrentCulture)}.";
|
||||
$"Mana Cost: {manaCost.ToString(CultureInfo.InvariantCulture)}.";
|
||||
if (manaCost > 0)
|
||||
{
|
||||
manaCostText +=
|
||||
|
|
@ -1227,7 +1227,7 @@ public static class ItemAppraisalTextFormatter
|
|||
{
|
||||
string name = !string.IsNullOrWhiteSpace(metadata?.Name)
|
||||
? metadata.Name
|
||||
: $"Spell {id.ToString(CultureInfo.CurrentCulture)}";
|
||||
: $"Spell {id.ToString(CultureInfo.InvariantCulture)}";
|
||||
text.Append("\n~ ").Append(name).Append(": ");
|
||||
if (!string.IsNullOrWhiteSpace(metadata?.Description))
|
||||
text.Append(metadata.Description);
|
||||
|
|
@ -1293,7 +1293,7 @@ public static class ItemAppraisalTextFormatter
|
|||
if (!string.IsNullOrWhiteSpace(gemName))
|
||||
{
|
||||
suffix =
|
||||
$", set with {gemCount.ToString(CultureInfo.CurrentCulture)} "
|
||||
$", set with {gemCount.ToString(CultureInfo.InvariantCulture)} "
|
||||
+ gemName;
|
||||
}
|
||||
}
|
||||
|
|
@ -1324,7 +1324,7 @@ public static class ItemAppraisalTextFormatter
|
|||
private static string FormatRetailDamage(double damage)
|
||||
=> damage.ToString(
|
||||
damage > 10d ? "G4" : "G3",
|
||||
CultureInfo.CurrentCulture);
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
private static bool TryDamageTypeName(uint type, out string? name)
|
||||
{
|
||||
|
|
@ -1652,7 +1652,7 @@ public static class ItemAppraisalTextFormatter
|
|||
=> FormatSignedPercent(modifier - 1d);
|
||||
|
||||
private static string FormatSignedPercent(double modifier)
|
||||
=> modifier.ToString("+0%;-0%;0%", CultureInfo.CurrentCulture);
|
||||
=> modifier.ToString("+0%;-0%;0%", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Port of <c>AppraisalSystem::LockpickSuccessPercentToString
|
||||
|
|
@ -1698,7 +1698,7 @@ public static class ItemAppraisalTextFormatter
|
|||
private static string DamageTypeName(uint type)
|
||||
=> TryDamageTypeName(type, out string? name)
|
||||
? name!
|
||||
: $"type {type.ToString(CultureInfo.CurrentCulture)}";
|
||||
: $"type {type.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
private static string WeaponSubtype(int type) => type switch
|
||||
{
|
||||
|
|
@ -1774,7 +1774,7 @@ public static class ItemAppraisalTextFormatter
|
|||
52 => "Dirty Fighting",
|
||||
53 => "Challenge",
|
||||
54 => "Summoning",
|
||||
_ => $"Skill {skill.ToString(CultureInfo.CurrentCulture)}",
|
||||
_ => $"Skill {skill.ToString(CultureInfo.InvariantCulture)}",
|
||||
};
|
||||
|
||||
private static string UsageSkillName(int skill)
|
||||
|
|
@ -1793,7 +1793,7 @@ public static class ItemAppraisalTextFormatter
|
|||
4 => "Coordination",
|
||||
5 => "Focus",
|
||||
6 => "Self",
|
||||
_ => $"Attribute {attribute.ToString(CultureInfo.CurrentCulture)}",
|
||||
_ => $"Attribute {attribute.ToString(CultureInfo.InvariantCulture)}",
|
||||
};
|
||||
|
||||
private static string SecondaryAttributeName(int attribute) => attribute switch
|
||||
|
|
@ -1804,7 +1804,7 @@ public static class ItemAppraisalTextFormatter
|
|||
4 => "Stamina",
|
||||
5 => "Max Mana",
|
||||
6 => "Mana",
|
||||
_ => $"Vital {attribute.ToString(CultureInfo.CurrentCulture)}",
|
||||
_ => $"Vital {attribute.ToString(CultureInfo.InvariantCulture)}",
|
||||
};
|
||||
|
||||
private sealed class RetailReportBuilder
|
||||
|
|
|
|||
|
|
@ -100,13 +100,13 @@ public sealed class LinkStatusUiController : IRetainedPanelController
|
|||
string ping = value.RoundTripSeconds is double seconds
|
||||
&& double.IsFinite(seconds)
|
||||
&& seconds >= 0d
|
||||
? (seconds * 1000d).ToString("F0", CultureInfo.CurrentCulture)
|
||||
? (seconds * 1000d).ToString("F0", CultureInfo.InvariantCulture)
|
||||
: "????";
|
||||
string body = _strings.Description
|
||||
+ _strings.Legend
|
||||
+ _strings.DisconnectWarning
|
||||
+ _strings.PacketLossPrefix
|
||||
+ value.PacketLossPercentage.ToString("F2", CultureInfo.CurrentCulture)
|
||||
+ value.PacketLossPercentage.ToString("F2", CultureInfo.InvariantCulture)
|
||||
+ _strings.PingPrefix
|
||||
+ ping;
|
||||
_lines = IndicatorDetailText.Shape(_mainText, body);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
|
@ -126,13 +127,17 @@ internal sealed class LiveWorldOriginState
|
|||
if (transitInFlight)
|
||||
return false;
|
||||
|
||||
throw new InvalidOperationException(
|
||||
"World-frame owners disagree: Runtime centre "
|
||||
// Invariant: an invariant-failure message is read from logs and crash
|
||||
// reports by whoever is diagnosing it, so its numbers must not depend
|
||||
// on the reporter's locale (sv-SE renders -192 with U+2212).
|
||||
throw new InvalidOperationException(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"World-frame owners disagree: Runtime centre "
|
||||
+ $"({runtimeCenterX},{runtimeCenterY}) vs streamed origin "
|
||||
+ $"({CenterX},{CenterY}) while projecting landblock "
|
||||
+ $"0x{projectingLandblockId:X8}. That offsets the entity by "
|
||||
+ $"({(runtimeCenterX - CenterX) * 192f:F0}m,"
|
||||
+ $"{(runtimeCenterY - CenterY) * 192f:F0}m) from its geometry.");
|
||||
+ $"{(runtimeCenterY - CenterY) * 192f:F0}m) from its geometry."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Runtime.World;
|
||||
|
|
@ -185,10 +186,11 @@ public sealed class RuntimeWorldEnvironmentState
|
|||
|
||||
if (definition.DayGroups.Count > 0)
|
||||
{
|
||||
_log(
|
||||
_log(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"sky: loaded Region 0x13000000 — {definition.DayGroups.Count} day groups, "
|
||||
+ $"SkyDesc.TickSize={definition.SourceTickSize} (throttle, not rate), "
|
||||
+ $"LightTickSize={definition.LightTickSize}");
|
||||
+ $"LightTickSize={definition.LightTickSize}"));
|
||||
RefreshDayGroup();
|
||||
}
|
||||
|
||||
|
|
@ -285,10 +287,11 @@ public sealed class RuntimeWorldEnvironmentState
|
|||
Weather.SetKindFromDayGroupName(group.Name);
|
||||
_revision++;
|
||||
|
||||
_log(
|
||||
_log(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"sky: PY{absoluteYear} day{dayOfYear} → DayGroup[{index}] \"{group.Name}\" "
|
||||
+ $"(Chance={group.ChanceOfOccur:F2}, {group.SkyObjectCount} objects, "
|
||||
+ $"{group.Sky.KeyframeCount} keyframes, weather={Weather.Kind})");
|
||||
+ $"{group.Sky.KeyframeCount} keyframes, weather={Weather.Kind})"));
|
||||
}
|
||||
|
||||
public string CycleTimeOfDay()
|
||||
|
|
@ -308,7 +311,11 @@ public sealed class RuntimeWorldEnvironmentState
|
|||
if (selection.HasValue)
|
||||
{
|
||||
WorldTime.SetDebugTime(selection.Value);
|
||||
return $"Time override = {selection.Value:F2}";
|
||||
// Invariant: this toast is player-visible text, and a culture
|
||||
// with a decimal comma would render it "Time override = 0,00".
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Time override = {selection.Value:F2}");
|
||||
}
|
||||
|
||||
WorldTime.ClearDebugTime();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
|
|
@ -207,7 +208,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
|||
var fps = FpsProvider?.Invoke();
|
||||
ShowSystemMessage(fps is null
|
||||
? "Framerate: (provider unavailable)"
|
||||
: $"Framerate: {fps.Value:F1} FPS");
|
||||
: string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Framerate: {fps.Value:F1} FPS"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -221,7 +224,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
|
|||
var pos = PositionProvider?.Invoke();
|
||||
ShowSystemMessage(pos is null
|
||||
? "Location: (provider unavailable)"
|
||||
: $"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})");
|
||||
: string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Location: ({pos.Value.X:F1}, {pos.Value.Y:F1}, {pos.Value.Z:F1})"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
|
||||
|
|
@ -81,7 +82,9 @@ public sealed class FramebufferResizeControllerTests
|
|||
|
||||
private sealed class Camera(List<string> calls) : IFramebufferCameraTarget
|
||||
{
|
||||
public void SetAspect(float aspect) => calls.Add($"camera:{aspect:F3}");
|
||||
public void SetAspect(float aspect) => calls.Add(
|
||||
// Invariant: compared against literal golden strings.
|
||||
string.Create(CultureInfo.InvariantCulture, $"camera:{aspect:F3}"));
|
||||
}
|
||||
|
||||
private sealed class DevTools(List<string> calls) : IFramebufferDevToolsTarget
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Update;
|
||||
|
||||
|
|
@ -278,7 +279,11 @@ public sealed class RuntimeResourceSlotTests
|
|||
|
||||
private sealed class RecordingUpdateRoot(List<string> calls) : IGameUpdateFrameRoot
|
||||
{
|
||||
public void Tick(UpdateFrameInput input) => calls.Add($"update:{input.HostDeltaSeconds}");
|
||||
public void Tick(UpdateFrameInput input) => calls.Add(
|
||||
// Invariant: compared against literal golden strings.
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"update:{input.HostDeltaSeconds}"));
|
||||
}
|
||||
|
||||
private sealed class RecordingRenderRoot(List<string> calls) : IGameRenderFrameRoot
|
||||
|
|
@ -286,7 +291,9 @@ public sealed class RuntimeResourceSlotTests
|
|||
public RenderFrameOutcome Render(RenderFrameInput input)
|
||||
{
|
||||
calls.Add(
|
||||
$"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}");
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"render:{input.DeltaSeconds}:{input.ViewportWidth}:{input.ViewportHeight}"));
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
|
|
@ -511,7 +512,9 @@ public sealed class VendorUiControllerTests
|
|||
// to the singular name UNCHANGED (not an invented "Arrows" + "s").
|
||||
Assert.Equal("100 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"cost {2000:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -539,7 +542,9 @@ public sealed class VendorUiControllerTests
|
|||
|
||||
Assert.Equal("Bread", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
}
|
||||
|
||||
|
|
@ -587,7 +592,9 @@ public sealed class VendorUiControllerTests
|
|||
// selection.
|
||||
Assert.Equal("Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"costs {20:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
// Player drags the slider to 40 AFTER selecting -- no re-click, no
|
||||
|
|
@ -597,7 +604,9 @@ public sealed class VendorUiControllerTests
|
|||
// SellPrice = ceil(2.0*10*40 - 0.1) = 800.
|
||||
Assert.Equal("40 Arrows", GetText(h.ItemNameText));
|
||||
Assert.Equal(
|
||||
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)",
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"cost {800:N0}p (you have {Harness.DefaultPlayerCoinValue:N0}p)"),
|
||||
GetText(h.ItemCostText));
|
||||
|
||||
h.BuyButton.OnClick!.Invoke();
|
||||
|
|
@ -2540,14 +2549,22 @@ public sealed class VendorUiControllerTests
|
|||
// Before staging: zero staged count/value, still grammatically
|
||||
// plural ("0 items"), purse text is live regardless of staging.
|
||||
Assert.Equal("Buying 0 items worth 0p", GetText(h.BuyListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.BuyPurseText));
|
||||
|
||||
h.AddButton.OnClick!.Invoke();
|
||||
|
||||
// ComputeBuyTransactionValue: sellRate 1.5 * value 500 * quantity 1
|
||||
// = 750, ceil(750 - 0.1) = 750.
|
||||
Assert.Equal("Buying 1 item worth 750p", GetText(h.BuyListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.BuyPurseText));
|
||||
|
||||
h.BuyClearListButton.OnClick!.Invoke();
|
||||
|
||||
|
|
@ -2565,7 +2582,11 @@ public sealed class VendorUiControllerTests
|
|||
{
|
||||
var h = new Harness();
|
||||
h.State.Apply(VendorGuid, Profile(), Array.Empty<VendorShopItem>());
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.BuyPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.BuyPurseText));
|
||||
|
||||
var bundle = new PropertyBundle();
|
||||
bundle.Ints[(uint)PropertyInt.CoinValue] = 42;
|
||||
|
|
@ -2584,7 +2605,11 @@ public sealed class VendorUiControllerTests
|
|||
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
|
||||
|
||||
Assert.Equal("Selling 0 items worth 0p", GetText(h.SellListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.SellPurseText));
|
||||
|
||||
h.Controller.HandleDropRelease(
|
||||
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
|
||||
|
|
@ -2592,7 +2617,11 @@ public sealed class VendorUiControllerTests
|
|||
// ComputeSellTransactionValue: SellProfile's BuyPrice 1.0 * value
|
||||
// 100 * quantity 1 = 100, floor(100 + 0.1) = 100.
|
||||
Assert.Equal("Selling 1 item worth 100p", GetText(h.SellListText));
|
||||
Assert.Equal($"You have {Harness.DefaultPlayerCoinValue:N0}p", GetText(h.SellPurseText));
|
||||
Assert.Equal(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"You have {Harness.DefaultPlayerCoinValue:N0}p"),
|
||||
GetText(h.SellPurseText));
|
||||
|
||||
h.SellClearListButton.OnClick!.Invoke();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Text;
|
||||
|
|
@ -184,16 +185,20 @@ public sealed class AnimationSequencerCutoverTraceTests
|
|||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
uint id = n.Value.Anim is null ? 0u : loader.IdOf(n.Value.Anim);
|
||||
sb.Append($"{id:X}@{n.Value.Framerate:F1}");
|
||||
// Invariant throughout: this trace is compared against literal
|
||||
// golden strings, so it must not follow the machine's locale.
|
||||
sb.Append(CultureInfo.InvariantCulture, $"{id:X}@{n.Value.Framerate:F1}");
|
||||
if (ReferenceEquals(n, core.FirstCyclicNode)) sb.Append('*');
|
||||
if (ReferenceEquals(n, core.CurrAnimNode)) sb.Append('^');
|
||||
}
|
||||
var v = core.Velocity;
|
||||
var o = core.Omega;
|
||||
sb.Append($" | frame={core.FrameNumber:F1}");
|
||||
sb.Append($" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})");
|
||||
sb.Append($" om=({o.X:F2},{o.Y:F2},{o.Z:F2})");
|
||||
sb.Append($" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}");
|
||||
sb.Append(CultureInfo.InvariantCulture, $" | frame={core.FrameNumber:F1}");
|
||||
sb.Append(CultureInfo.InvariantCulture, $" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})");
|
||||
sb.Append(CultureInfo.InvariantCulture, $" om=({o.X:F2},{o.Y:F2},{o.Z:F2})");
|
||||
sb.Append(
|
||||
CultureInfo.InvariantCulture,
|
||||
$" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
|
|
@ -21,7 +22,12 @@ public class MotionInterpreterFunnelTests
|
|||
public readonly List<string> Calls = new();
|
||||
public bool ApplyMotion(uint motion, float speed)
|
||||
{
|
||||
Calls.Add($"DIM {motion:x8}@{speed:F2}");
|
||||
// Invariant: this trace is compared against literal golden
|
||||
// strings, so it must not follow the machine's locale (sv-SE
|
||||
// renders 1.00 as "1,00" and -1.20 with a Unicode minus).
|
||||
Calls.Add(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"DIM {motion:x8}@{speed:F2}"));
|
||||
// R3-W5: a style/stance id (>= 0x80000000, i.e. negative as
|
||||
// int32) has no locomotion MotionData entry in the dat — retail's
|
||||
// real CMotionTable::DoObjectMotion genuinely fails for it
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
|
@ -251,7 +252,10 @@ public sealed class RuntimeSimulationFixtureHostTests
|
|||
|
||||
public bool SendAttack(AttackHeight height, float power)
|
||||
{
|
||||
Trace.Add($"attack:{height}:{power:0.0}");
|
||||
// Invariant: compared against literal golden traces.
|
||||
Trace.Add(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"attack:{height}:{power:0.0}"));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Combat;
|
||||
|
|
@ -748,7 +749,10 @@ internal sealed class NoWindowGameRuntimeHost : IDisposable
|
|||
Trace.Add("attack:prepare");
|
||||
public bool SendAttack(AttackHeight height, float power)
|
||||
{
|
||||
Trace.Add($"attack:{height}:{power:0.0}");
|
||||
// Invariant: compared against literal golden traces.
|
||||
Trace.Add(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"attack:{height}:{power:0.0}"));
|
||||
return true;
|
||||
}
|
||||
public void SendCancelAttack() { }
|
||||
|
|
|
|||
15
tests/Directory.Build.props
Normal file
15
tests/Directory.Build.props
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<Project>
|
||||
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
|
||||
|
||||
<!-- Test assemblies only. The fixture projects under tests/ are ordinary
|
||||
libraries and executables (plugins, child processes), and a
|
||||
ModuleInitializer in a library is exactly what CA2255 exists to flag. -->
|
||||
<ItemGroup Condition="$(MSBuildProjectName.EndsWith('.Tests'))">
|
||||
<!-- One opt-in culture knob for every test assembly. See
|
||||
TestCultureInitializer for why: a locale-dependent expectation should
|
||||
be reproducible on any machine, not only on one whose Windows locale
|
||||
happens to match. Does nothing unless ACDREAM_TEST_CULTURE is set. -->
|
||||
<Compile Include="$(MSBuildThisFileDirectory)TestCultureInitializer.cs"
|
||||
Link="TestCultureInitializer.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
54
tests/TestCultureInitializer.cs
Normal file
54
tests/TestCultureInitializer.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AcDream.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Lets a test run pin the ambient culture, so a locale-dependent failure can
|
||||
/// be reproduced on any machine instead of only on one with the right Windows
|
||||
/// locale.
|
||||
///
|
||||
/// <para><b>Why this exists.</b> On 2026-08-19 the Windows CI runner went red on
|
||||
/// 37 tests across four assemblies with failures like
|
||||
/// <c>Expected: "You have 1 500p" / Actual: "You have 1,500p"</c>. The
|
||||
/// production code was right — it formats retail text with
|
||||
/// <see cref="CultureInfo.InvariantCulture"/>, so every player sees retail's
|
||||
/// comma. The TESTS were wrong: they built their expected strings with
|
||||
/// <c>$"{value:N0}"</c>, which uses the machine's current culture, so they only
|
||||
/// passed on a machine that happens to format like the invariant culture. The
|
||||
/// runner is Swedish (space as the group separator), and the tests had been
|
||||
/// passing there only because its registry locale had been pinned by hand —
|
||||
/// a machine-state fix that silently came undone.</para>
|
||||
///
|
||||
/// <para>The expectations are fixed to be invariant. This knob is the
|
||||
/// apparatus that makes such a break reproducible next time:
|
||||
/// <c>ACDREAM_TEST_CULTURE=sv-SE dotnet test ...</c> runs the suite as the
|
||||
/// Swedish runner sees it. Unset (the default, and what CI runs) changes
|
||||
/// nothing at all.</para>
|
||||
/// </summary>
|
||||
internal static class TestCultureInitializer
|
||||
{
|
||||
internal const string CultureVariable = "ACDREAM_TEST_CULTURE";
|
||||
|
||||
[ModuleInitializer]
|
||||
internal static void Initialize()
|
||||
{
|
||||
string? requested = Environment.GetEnvironmentVariable(CultureVariable);
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var culture = CultureInfo.GetCultureInfo(requested);
|
||||
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
// A typo in an opt-in diagnostic must not fail an unrelated suite;
|
||||
// the run simply keeps the machine's own culture.
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue