From da0fcd06ee98f065b0f926ab4733dce62c165ab7 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Fri, 4 Sep 2026 20:24:28 -0400 Subject: [PATCH] Fix Sunset range scale while preserving saved placements --- docs/sunset-scale-correction.md | 65 +++ lib/const/maps.dart | 2 +- lib/const/settings.dart | 2 +- lib/migrations/ability_scale_migration.dart | 3 +- .../canonical_coordinates_migration.dart | 3 +- .../custom_circle_wrapper_migration.dart | 3 +- lib/migrations/map_scale_history.dart | 6 + lib/migrations/sunset_scale_migration.dart | 77 +++ lib/providers/strategy_provider.dart | 57 ++- scripts/audit_sunset_scale.py | 182 +++++++ test/fixtures/map_calibration/sunset.json | 453 ++++++++++++++++++ test/folder_icon_registry_test.dart | 5 +- test/sunset_scale_migration_test.dart | 358 ++++++++++++++ 13 files changed, 1200 insertions(+), 16 deletions(-) create mode 100644 docs/sunset-scale-correction.md create mode 100644 lib/migrations/map_scale_history.dart create mode 100644 lib/migrations/sunset_scale_migration.dart create mode 100644 scripts/audit_sunset_scale.py create mode 100644 test/fixtures/map_calibration/sunset.json create mode 100644 test/sunset_scale_migration_test.dart diff --git a/docs/sunset-scale-correction.md b/docs/sunset-scale-correction.md new file mode 100644 index 00000000..8ad513f6 --- /dev/null +++ b/docs/sunset-scale-correction.md @@ -0,0 +1,65 @@ +# Sunset scale correction + +The Sunset map scale is now `1.06`, up from `0.9502102049421427`. +The SVG artwork and its 416 by 473 viewBox are unchanged. + +A nominal 30 m Crosscut radius previously represented 26.87 m against the +extracted minimap geometry. It now represents 29.97 m. Six fixed map corners +establish the transform, and ten separate corners check it. The held-out error +is 0.433 SVG units RMS and 0.818 units maximum. The affine axis ratio is 1.000118, +which does not support stretching the artwork to correct the range. + +The calculation uses Sunset's extracted `Juliett_UIData` multiplier of +0.000078 per centimeter, 447.5358 SVG units per minimap UV unit, the SVG height +of 473, and Icarus's virtual height of 831: + +```text +SVG units per meter = 0.000078 * 100 * 447.5358 = 3.490779 +Map scale = 3.490779 * 831 / (5.78 * 473) = 1.061047 +Rounded runtime value = 1.06 +``` + +The [landmark fixture](../test/fixtures/map_calibration/sunset.json) pins the +coordinates and hashes of the source files. Reproduce the measurement with: + +```powershell +python -m pip install numpy svgpathtools matplotlib +python scripts/audit_sunset_scale.py --check +``` + +The check rejects a scale error above 0.5% or excessive held-out landmark error. +Use `--fmodel-content ` to verify the extracted source hashes. + +## Saved placements + +Data version 98 preserves the anchor of each existing Sunset ability, lineup +ability, and scale-dependent utility. The migration runs after canonical +coordinate conversion, so it applies the same calculation to attack and defense +pages. Agent positions and fixed-size anchors do not move. + +```text +newPosition = oldPosition + (oldAnchor - newAnchor) * 1000 / 831 +``` + +Historical migrations 39, 45, and 97 retain the old Sunset scale. Each migration +is selected using the original input version, because intermediate helpers can +stamp the current version before later stages run. Current-version data does +not receive the correction twice. No Hive fields or adapters changed. + +Regression coverage includes every ability and utility type, lineup metadata, +deleted flags, both page sides, pre-page imports, versions 16/38/39/44/45/96/97, +other maps, source immutability, and ZIP JSON export/import. + +## Accuracy limits + +The corrected range is about 0.84% larger than the visible circle in the +reporter's screenshot. That residual persists when fitting Riot's minimap UVs +directly to the screenshot without the SVG. It is accepted as a visual +discrepancy, not fitted away by changing the physical scale. The minimap outline +is not a 3D collision mesh, and a current live activation-boundary test has not +been performed. + +The local Crosscut extraction contains older 24 m tuning. The 30 m specification +comes from [Riot's patch 13.00 notes](https://playvalorant.com/en-us/news/game-updates/valorant-patch-notes-13-00/). +Custom-shape diameter semantics and vision-boundary projection are separate +issues and are not changed here. diff --git a/lib/const/maps.dart b/lib/const/maps.dart index c89d48f4..be087d99 100644 --- a/lib/const/maps.dart +++ b/lib/const/maps.dart @@ -70,7 +70,7 @@ class Maps { MapValue.breeze: 1.01, //modified MapValue.lotus: 1.24, //modified MapValue.icebox: 1.03, //modiefied - MapValue.sunset: 0.9502102049421427, + MapValue.sunset: 1.06, MapValue.split: 1.1920129279062075, //modified MapValue.haven: 1.06, //modified MapValue.fracture: 1.21, //modified diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 3860a108..f94cd6fe 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -100,7 +100,7 @@ class Settings { static final Uri dicordLink = Uri.parse("https://discord.gg/PN2uKwCqYB"); static const Duration autoSaveOffset = Duration(seconds: 15); - static const int versionNumber = 97; + static const int versionNumber = 98; static const String versionName = "4.6.1"; static final Uri desktopUpdaterArchiveUrl = buildDesktopUpdaterArchiveUrl(kResolvedUpdateChannel); diff --git a/lib/migrations/ability_scale_migration.dart b/lib/migrations/ability_scale_migration.dart index 031cf205..a8dd3fec 100644 --- a/lib/migrations/ability_scale_migration.dart +++ b/lib/migrations/ability_scale_migration.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/migrations/map_scale_history.dart'; import 'package:icarus/providers/strategy_page.dart'; class AbilityScaleMigration { @@ -71,7 +72,7 @@ class AbilityScaleMigration { if (data == null) return ability; final oldMapScale = _oldMapScale[map] ?? 1.0; - final newMapScale = Maps.mapScale[map] ?? 1.0; + final newMapScale = mapScaleBeforeVersion98(map); final oldAnchor = _oldAnchor( data: data, diff --git a/lib/migrations/canonical_coordinates_migration.dart b/lib/migrations/canonical_coordinates_migration.dart index b3bd57cd..527ba068 100644 --- a/lib/migrations/canonical_coordinates_migration.dart +++ b/lib/migrations/canonical_coordinates_migration.dart @@ -9,6 +9,7 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/placed_media_geometry.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/migrations/map_scale_history.dart'; import 'package:icarus/providers/strategy_page.dart'; abstract final class CanonicalCoordinatesMigration { @@ -19,7 +20,7 @@ abstract final class CanonicalCoordinatesMigration { required List pages, required MapValue map, }) { - final mapScale = Maps.mapScale[map] ?? 1; + final mapScale = mapScaleBeforeVersion98(map); return [ for (final page in pages) page.isAttack ? page : _migrateDefensePage(page, mapScale), diff --git a/lib/migrations/custom_circle_wrapper_migration.dart b/lib/migrations/custom_circle_wrapper_migration.dart index eff3f078..a8b501be 100644 --- a/lib/migrations/custom_circle_wrapper_migration.dart +++ b/lib/migrations/custom_circle_wrapper_migration.dart @@ -1,6 +1,7 @@ import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/utilities.dart'; +import 'package:icarus/migrations/map_scale_history.dart'; import 'package:icarus/providers/strategy_page.dart'; class CustomCircleWrapperMigration { @@ -50,7 +51,7 @@ class CustomCircleWrapperMigration { return utility; } - final mapScale = Maps.mapScale[map] ?? 1.0; + final mapScale = mapScaleBeforeVersion98(map); final actualDiameterVirtual = CustomCircleUtility.diameterInVirtual( diameterMeters: diameterMeters, mapScale: mapScale, diff --git a/lib/migrations/map_scale_history.dart b/lib/migrations/map_scale_history.dart new file mode 100644 index 00000000..e399590c --- /dev/null +++ b/lib/migrations/map_scale_history.dart @@ -0,0 +1,6 @@ +import 'package:icarus/const/maps.dart'; + +// Migrations through version 97 must reconstruct the scale used at the time. +// Reading Sunset's live scale would apply the version 98 correction twice. +double mapScaleBeforeVersion98(MapValue map) => + map == MapValue.sunset ? 0.9502102049421427 : Maps.mapScale[map] ?? 1.0; diff --git a/lib/migrations/sunset_scale_migration.dart b/lib/migrations/sunset_scale_migration.dart new file mode 100644 index 00000000..98b67939 --- /dev/null +++ b/lib/migrations/sunset_scale_migration.dart @@ -0,0 +1,77 @@ +import 'dart:ui'; + +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/strategy_page.dart'; + +abstract final class SunsetScaleMigration { + static const int version = 98; + static const double _oldScale = 0.9502102049421427; + static const double _newScale = 1.06; + static const double _virtualToWorld = 1000 / 831; + + // Positions are canonical after version 97, including defense pages. + static List migratePages({ + required List pages, + required MapValue map, + }) { + if (map != MapValue.sunset) return pages; + return [ + for (final page in pages) + page.copyWith( + abilityData: [ + for (final ability in page.abilityData) _ability(ability), + ], + utilityData: [ + for (final utility in page.utilityData) _utility(utility), + ], + lineUpGroups: [ + for (final group in page.lineUpGroups) + group.copyWith( + items: [ + for (final item in group.items) + item.copyWith(ability: _ability(item.ability)), + ], + ), + ], + ), + ]; + } + + static PlacedAbility _ability(PlacedAbility ability) { + final data = ability.data.abilityData; + if (data == null) return ability; + final delta = (data.getAnchorPoint( + mapScale: _oldScale, + abilitySize: Settings.abilitySize, + ) - + data.getAnchorPoint( + mapScale: _newScale, + abilitySize: Settings.abilitySize, + )) * + _virtualToWorld; + if (delta == Offset.zero) return ability; + return ability.copyWith(position: ability.position + delta) + ..isDeleted = ability.isDeleted; + } + + static PlacedUtility _utility(PlacedUtility utility) { + final data = UtilityData.utilityWidgets[utility.type]!; + Offset anchor(double scale) => data.getAnchorPoint( + id: utility.id, + length: utility.length, + rotation: utility.rotation, + mapScale: scale, + agentSize: Settings.agentSize, + abilitySize: Settings.abilitySize, + diameterMeters: utility.customDiameter, + widthMeters: utility.customWidth, + rectLengthMeters: utility.customLength, + ); + final delta = (anchor(_oldScale) - anchor(_newScale)) * _virtualToWorld; + if (delta == Offset.zero) return utility; + return utility.copyWith(position: utility.position + delta); + } +} diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index e4883aa3..630c1d51 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -24,6 +24,7 @@ import 'package:icarus/migrations/canonical_coordinates_migration.dart'; import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; import 'package:icarus/migrations/lineup_group_migration.dart'; import 'package:icarus/migrations/page_name_provenance_migration.dart'; +import 'package:icarus/migrations/sunset_scale_migration.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; @@ -597,8 +598,11 @@ class StrategyProvider extends Notifier { ); } - static StrategyData migrateToCurrentVersion(StrategyData strat, - {bool forceAbilityScale = false}) { + static StrategyData migrateToCurrentVersion( + StrategyData strat, { + bool forceAbilityScale = false, + }) { + final originalVersion = strat.versionNumber; final needsCanonicalCoordinatesMigration = strat.versionNumber < CanonicalCoordinatesMigration.version; final needsAbilityVisionMigration = @@ -606,11 +610,23 @@ class StrategyProvider extends Notifier { final needsPageNameProvenanceMigration = strat.versionNumber < PageNameProvenanceMigration.version; final worldMigrated = migrateToWorld16x9(strat); - final abilityScaleMigrated = - migrateAbilityScale(worldMigrated, force: forceAbilityScale); - final squareAoeMigrated = migrateSquareAoeCenter(abilityScaleMigrated); - final customCircleMigrated = migrateCustomCircleWrapper(squareAoeMigrated); - final lineUpGroupMigrated = migrateLineUpGroups(customCircleMigrated); + final abilityScaleMigrated = migrateAbilityScale( + worldMigrated, + force: + forceAbilityScale || originalVersion < AbilityScaleMigration.version, + ); + final squareAoeMigrated = migrateSquareAoeCenter( + abilityScaleMigrated, + force: originalVersion < SquareAoeCenterMigration.version, + ); + final customCircleMigrated = migrateCustomCircleWrapper( + squareAoeMigrated, + force: originalVersion < CustomCircleWrapperMigration.version, + ); + final lineUpGroupMigrated = migrateLineUpGroups( + customCircleMigrated, + force: originalVersion < LineUpGroupMigration.version, + ); final abilityVisionMigrated = migrateAbilityVisionCones( lineUpGroupMigrated, force: needsAbilityVisionMigration, @@ -619,10 +635,31 @@ class StrategyProvider extends Notifier { abilityVisionMigrated, force: needsPageNameProvenanceMigration, ); - return migrateCanonicalCoordinates( + final canonicalMigrated = migrateCanonicalCoordinates( pageNameMigrated, force: needsCanonicalCoordinatesMigration, ); + return migrateSunsetScale( + canonicalMigrated, + force: originalVersion < SunsetScaleMigration.version, + ); + } + + static StrategyData migrateSunsetScale( + StrategyData strat, { + bool force = false, + }) { + if (!force && strat.versionNumber >= SunsetScaleMigration.version) { + return strat; + } + return strat.copyWith( + pages: SunsetScaleMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ), + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); } static StrategyData migrateCanonicalCoordinates(StrategyData strat, @@ -776,10 +813,12 @@ class StrategyProvider extends Notifier { abilityVisionMigrated, force: originalVersion < PageNameProvenanceMigration.version, ); - return migrateCanonicalCoordinates( + final canonicalMigrated = migrateCanonicalCoordinates( pageNameMigrated, force: originalVersion < CanonicalCoordinatesMigration.version, ); + return migrateSunsetScale(canonicalMigrated, + force: originalVersion < SunsetScaleMigration.version); } static StrategyData migrateToWorld16x9(StrategyData strat, diff --git a/scripts/audit_sunset_scale.py b/scripts/audit_sunset_scale.py new file mode 100644 index 00000000..4f3fe9fa --- /dev/null +++ b/scripts/audit_sunset_scale.py @@ -0,0 +1,182 @@ +"""Measure Sunset against pinned Riot landmarks, independently of ability sizes. + +python -m pip install numpy svgpathtools matplotlib +python scripts/audit_sunset_scale.py --output artifacts/sunset-audit +Add --fmodel-content to verify the original extracted files. +Add --check to reject a runtime scale more than 0.5% from the measured value. +""" + +import argparse +import hashlib +import json +from pathlib import Path +import re +import xml.etree.ElementTree as ET + +import numpy as np +from svgpathtools import parse_path + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/map_calibration/sunset.json" + + +def load_artwork(path): + root = ET.parse(path).getroot() + base = max( + (e for e in root.iter() if e.tag.endswith("path") + and e.get("fill", "").upper() == "#271406"), + key=lambda e: len(e.get("d", "")), + ) + return root, parse_path(base.get("d")) + + +def fit_uniform(uv, xy): + """Fit one scale and a translation; do not stretch or rotate the artwork.""" + a, b = uv - uv.mean(axis=0), xy - xy.mean(axis=0) + scale = float((a * b).sum() / (a * a).sum()) + return scale, xy.mean(axis=0) - scale * uv.mean(axis=0) + + +def verify_sources(fixture, content): + for source in fixture["sourceFiles"]: + path = content / source["path"] + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != source["sha256"]: + raise ValueError(f"Source changed: {path}. Recalibrate; do not reuse old landmarks.") + table = json.loads((content / fixture["sourceFiles"][1]["path"]).read_text()) + vertices = [] + for row in table[0]["Rows"].values(): + if row["Type"] == "v": + vertices.append([row["X"], row["Y"]]) + elif vertices: + break + for landmark in fixture["landmarks"]: + if not np.allclose(vertices[landmark["riotVertex"]], landmark["uv"], atol=1e-9): + raise ValueError(f"Riot landmark changed: {landmark['name']}") + + +def analyze(fixture, artwork, runtime_scale, base_meters, virtual_height): + root, path = load_artwork(artwork) + viewbox = [float(v) for v in root.get("viewBox").split()] + subpaths = path.continuous_subpaths() + landmarks = fixture["landmarks"] + for item in landmarks: + point = subpaths[item["svgSubpath"]][item["svgSegment"]].start + if not np.allclose([point.real, point.imag], item["svg"], atol=1e-6): + raise ValueError(f"SVG landmark changed: {item['name']}. Recheck its correspondence.") + uv = np.array([p["uv"] for p in landmarks]) + xy = np.array([p["svg"] for p in landmarks]) + training = np.array([p["role"] == "fit" for p in landmarks]) + scale, translation = fit_uniform(uv[training], xy[training]) + errors = np.linalg.norm(uv * scale + translation - xy, axis=1) + affine = np.linalg.lstsq( + np.column_stack([uv[training], np.ones(training.sum())]), xy[training], rcond=None + )[0] + axis_scales = np.linalg.norm(affine[:2], axis=1) + uv_per_meter = abs(fixture["uiData"]["xMultiplier"]) * fixture["centimetersPerMeter"] + if abs(abs(fixture["uiData"]["yMultiplier"]) * fixture["centimetersPerMeter"] - uv_per_meter) > 1e-12: + raise ValueError("Anisotropic Riot projection needs a separate calibration.") + svg_per_meter = scale * uv_per_meter + recommended = svg_per_meter * virtual_height / (viewbox[3] * base_meters) + actual_svg_per_meter = base_meters * runtime_scale * viewbox[3] / virtual_height + radius_meters = fixture["currentRadiusMeters"] + # Holding out the reported area prevents tuning the global scale to this placement. + checks = errors[~training] + if checks.max() > 1.25 or np.sqrt(np.mean(checks ** 2)) > 0.75: + raise ValueError("Artwork check failed: inspect local geometry before changing scale.") + result = { + "svgUnitsPerUv": scale, + "svgTranslation": translation.tolist(), + "svgUnitsPerMeter": svg_per_meter, + "fitLandmarks": int(training.sum()), + "heldOutLandmarks": int((~training).sum()), + "heldOutRmsSvg": float(np.sqrt(np.mean(checks ** 2))), + "heldOutMaxSvg": float(checks.max()), + "affineAxisScales": axis_scales.tolist(), + "affineAxisRatio": float(axis_scales[0] / axis_scales[1]), + "recommendedMapScale": recommended, + "runtimeMapScale": runtime_scale, + "rangeErrorPercent": (actual_svg_per_meter / svg_per_meter - 1) * 100, + "radiusSvgExpected": radius_meters * svg_per_meter, + "radiusSvgRuntime": radius_meters * actual_svg_per_meter, + "effectiveRadiusMeters": radius_meters * actual_svg_per_meter / svg_per_meter, + "landmarks": [dict(p, errorSvg=float(error)) for p, error in zip(landmarks, errors)], + } + return result, path + + +def plot(result, path, output): + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.patches import Circle + + fig, axes = plt.subplots(1, 2, figsize=(13, 7), layout="constrained") + for ax in axes: + for subpath in path.continuous_subpaths(): + points = [segment.point(t) for segment in subpath for t in np.linspace(0, 1, 12)] + ax.plot([p.real for p in points], [p.imag for p in points], color="#75614c", lw=0.9) + ax.set_aspect("equal") + ax.set_xlabel("SVG x") + ax.set_ylabel("SVG y") + ax = axes[0] + for i, item in enumerate(result["landmarks"]): + x, y = item["svg"] + projected = np.array(item["uv"]) * result["svgUnitsPerUv"] + result["svgTranslation"] + color = "#946500" if item["role"] == "fit" else "#006fc4" + ax.plot(x, y, "o", color=color, markersize=4) + ax.plot(*projected, "+", color="#dc2b40", markersize=6) + ax.annotate(str(i + 1), (x, y), xytext=(5, 4), textcoords="offset points", fontsize=8) + ax.set_xlim(-20, 445) + ax.set_ylim(480, -5) + ax.set_title("Riot landmarks on the unchanged artwork\n6 calibration points; 10 independent checks") + ax = axes[1] + # Approximate placement from the supplied Discord image; never used to fit scale. + center = (302, 340) + ax.add_patch(Circle(center, result["radiusSvgExpected"], fill=False, ec="#087f5b", lw=2, + label="30 m from Riot geometry")) + ax.add_patch(Circle(center, result["radiusSvgRuntime"], fill=False, ec="#cc334e", lw=2, + ls="--", label=f"Icarus at {result['runtimeMapScale']:.3f}: {result['effectiveRadiusMeters']:.2f} m")) + ax.plot(*center, "+", color="black") + for label, point in [("Mid", (210, 290)), ("A Lobby", (325, 303)), ("A Elbow", (408, 240))]: + ax.annotate(label, point, fontsize=10, backgroundcolor="white") + ax.set_xlim(160, 435) + ax.set_ylim(455, 195) + ax.legend(loc="lower left", fontsize=9) + ax.set_title("Same center, measured range correction\nIllustrative placement; scale fitted elsewhere") + fig.suptitle("Sunset / Veto Crosscut", fontsize=18) + fig.savefig(output / "sunset-calibration.png", dpi=190) + fig.savefig(output / "sunset-calibration.svg") + plt.close(fig) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fmodel-content", type=Path) + parser.add_argument("--output", type=Path, default=ROOT / "artifacts/sunset-audit") + parser.add_argument("--candidate-scale", type=float) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + fixture = json.loads(FIXTURE.read_text()) + if args.fmodel_content: + verify_sources(fixture, args.fmodel_content) + maps = (ROOT / "lib/const/maps.dart").read_text() + runtime_scale = float(re.search(r"MapValue.sunset:\s*([\d.]+)", maps).group(1)) + if args.candidate_scale is not None: + runtime_scale = args.candidate_scale + agents = (ROOT / "lib/const/agents.dart").read_text() + meters = float(re.search(r"inGameMeters\s*=\s*([\d.]+)", agents).group(1)) + coordinates = (ROOT / "lib/const/coordinate_system.dart").read_text() + virtual_height = float(re.search(r"_baseHeight\s*=\s*([\d.]+)", coordinates).group(1)) + result, path = analyze(fixture, ROOT / "assets/maps/sunset_map.svg", runtime_scale, meters, virtual_height) + args.output.mkdir(parents=True, exist_ok=True) + (args.output / "measurements.json").write_text(json.dumps(result, indent=2) + "\n") + plot(result, path, args.output) + print(json.dumps({k: v for k, v in result.items() if k != "landmarks"}, indent=2)) + if args.check and abs(result["rangeErrorPercent"]) > 0.5: + raise SystemExit("FAIL: range calibration differs by more than 0.5%.") + + +if __name__ == "__main__": + main() diff --git a/test/fixtures/map_calibration/sunset.json b/test/fixtures/map_calibration/sunset.json new file mode 100644 index 00000000..8a6ee784 --- /dev/null +++ b/test/fixtures/map_calibration/sunset.json @@ -0,0 +1,453 @@ +{ + "version": 1, + "map": "sunset", + "mapCode": "Juliett", + "ability": "Veto Crosscut", + "uiData": { + "xMultiplier": 7.8e-05, + "yMultiplier": -7.8e-05, + "xScalarToAdd": 0.5, + "yScalarToAdd": 0.515625 + }, + "centimetersPerMeter": 100, + "currentRadiusMeters": 30, + "rangeSource": "https://playvalorant.com/en-us/news/game-updates/valorant-patch-notes-13-00/", + "extractedRangeRadiusCm": 2400, + "sourceFiles": [ + { + "path": "Maps/Juliett/Juliett_UIData.json", + "sha256": "be779c19a5917f2aaf854a49995f89a15ae72934c5d209f22f83bb0327687bea" + }, + { + "path": "UI/InGame/Minimap/Maps/Juliett/Juliett_VisionCones.json", + "sha256": "d159fcbda65f50d9a8e11f986c3950cd03eb48b27b470d91e13699a55d586e94" + }, + { + "path": "Characters/Pine/S0/Ability_4/GameObject_Pine_4_UsableTeleport.json", + "sha256": "1dc08f0d3d06858e5c628577ecc005b296f42b347de183344438f83acd8a018c" + }, + { + "path": "Characters/Pine/S0/Ability_4/Ability_Pine_4_UsableTP.json", + "sha256": "2b0e9f9a64f97c0d2133265717251d0ab8864ce8bff937f4d0d130c331977133" + } + ], + "landmarks": [ + { + "name": "A Elbow northeast", + "role": "fit", + "svgSubpath": 0, + "svgSegment": 17, + "svg": [ + 414.436, + 138.496 + ], + "riotVertex": 281, + "uv": [ + 0.96875, + 0.28027344 + ] + }, + { + "name": "B Site west", + "role": "fit", + "svgSubpath": 0, + "svgSegment": 57, + "svg": [ + 1.53516, + 166.88 + ], + "riotVertex": 0, + "uv": [ + 0.045898438, + 0.34277344 + ] + }, + { + "name": "Attacker Spawn southwest", + "role": "fit", + "svgSubpath": 0, + "svgSegment": 34, + "svg": [ + 190.045, + 454.465 + ], + "riotVertex": 142, + "uv": [ + 0.46777344, + 0.9863281 + ] + }, + { + "name": "Defender Spawn northeast", + "role": "fit", + "svgSubpath": 0, + "svgSegment": 2, + "svg": [ + 274.66, + 27.6396 + ], + "riotVertex": 200, + "uv": [ + 0.65625, + 0.032226562 + ] + }, + { + "name": "B Lobby west", + "role": "fit", + "svgSubpath": 0, + "svgSegment": 44, + "svg": [ + 85.0791, + 335.039 + ], + "riotVertex": 57, + "uv": [ + 0.23339844, + 0.71875 + ] + }, + { + "name": "B Main southwest", + "role": "fit", + "svgSubpath": 0, + "svgSegment": 48, + "svg": [ + 6.35547, + 314.41 + ], + "riotVertex": 5, + "uv": [ + 0.056640625, + 0.671875 + ] + }, + { + "name": "A Lobby inside elbow", + "role": "check", + "svgSubpath": 0, + "svgSegment": 28, + "svg": [ + 295.546, + 307.191 + ], + "riotVertex": 209, + "uv": [ + 0.703125, + 0.65625 + ] + }, + { + "name": "A Lobby outer southeast", + "role": "check", + "svgSubpath": 0, + "svgSegment": 27, + "svg": [ + 351.242, + 307.191 + ], + "riotVertex": 255, + "uv": [ + 0.828125, + 0.65625 + ] + }, + { + "name": "A Lobby south doorway", + "role": "check", + "svgSubpath": 0, + "svgSegment": 30, + "svg": [ + 310.006, + 334.504 + ], + "riotVertex": 228, + "uv": [ + 0.734375, + 0.71777344 + ] + }, + { + "name": "A Elbow southeast", + "role": "check", + "svgSubpath": 0, + "svgSegment": 22, + "svg": [ + 414.436, + 251.495 + ], + "riotVertex": 284, + "uv": [ + 0.96875, + 0.53125 + ] + }, + { + "name": "Mid Bottom east wall top", + "role": "check", + "svgSubpath": 3, + "svgSegment": 0, + "svg": [ + 218.464, + 226.896 + ], + "riotVertex": 160, + "uv": [ + 0.53125, + 0.4765625 + ] + }, + { + "name": "Mid Bottom east wall bottom", + "role": "check", + "svgSubpath": 3, + "svgSegment": 1, + "svg": [ + 218.464, + 289.482 + ], + "riotVertex": 163, + "uv": [ + 0.53125, + 0.61621094 + ] + }, + { + "name": "Mid Tiles northwest", + "role": "check", + "svgSubpath": 4, + "svgSegment": 10, + "svg": [ + 253.274, + 250.995 + ], + "riotVertex": 184, + "uv": [ + 0.609375, + 0.53125 + ] + }, + { + "name": "Mid Tiles southeast", + "role": "check", + "svgSubpath": 4, + "svgSegment": 17, + "svg": [ + 323.358, + 278.772 + ], + "riotVertex": 241, + "uv": [ + 0.76464844, + 0.59277344 + ] + }, + { + "name": "A Main wall east", + "role": "check", + "svgSubpath": 7, + "svgSegment": 13, + "svg": [ + 386.017, + 223.076 + ], + "riotVertex": 278, + "uv": [ + 0.90527344, + 0.46777344 + ] + }, + { + "name": "Defender Spawn inner wall", + "role": "check", + "svgSubpath": 10, + "svgSegment": 4, + "svg": [ + 246.241, + 132.569 + ], + "riotVertex": 179, + "uv": [ + 0.59277344, + 0.26464844 + ] + } + ], + "figmaBaseline": { + "document": "Icarus Maps Latest One", + "page": "Map Assets", + "nodeId": "908:1601", + "name": "sunsent_map", + "exportSha256": "dd422225ade44ea32d40e7d2fea11e817581e82bba73cf791143d6ad4db323c7", + "landmarks": [ + { + "name": "A Elbow northeast", + "segment": 17, + "svg": [ + 413.807, + 138.561 + ] + }, + { + "name": "B Site west", + "segment": 54, + "svg": [ + 0.5, + 166.73 + ] + }, + { + "name": "Attacker Spawn southwest", + "segment": 0, + "svg": [ + 189.394, + 454.76 + ] + }, + { + "name": "Defender Spawn northeast", + "segment": 32, + "svg": [ + 273.71, + 27.6144 + ] + }, + { + "name": "B Lobby west", + "segment": 67, + "svg": [ + 84.1555, + 335.408 + ] + }, + { + "name": "B Main southwest", + "segment": 63, + "svg": [ + 5.2393, + 314.372 + ] + }, + { + "name": "A Lobby inside elbow", + "segment": 6, + "svg": [ + 294.839, + 307.299 + ] + }, + { + "name": "A Lobby outer southeast", + "segment": 7, + "svg": [ + 350.714, + 307.299 + ] + }, + { + "name": "A Lobby south doorway", + "segment": 4, + "svg": [ + 309.023, + 334.442 + ] + }, + { + "name": "A Elbow southeast", + "segment": 12, + "svg": [ + 413.81, + 251.375 + ] + }, + { + "name": "Mid Bottom east wall top", + "segment": 114, + "svg": [ + 217.935, + 227.177 + ] + }, + { + "name": "Mid Bottom east wall bottom", + "segment": 121, + "svg": [ + 217.935, + 288.829 + ] + }, + { + "name": "Mid Tiles northwest", + "segment": 141, + "svg": [ + 252.743, + 251.375 + ] + }, + { + "name": "Mid Tiles southeast", + "segment": 134, + "svg": [ + 322.006, + 278.461 + ] + }, + { + "name": "A Main wall east", + "segment": 161, + "svg": [ + 384.791, + 222.659 + ] + }, + { + "name": "Defender Spawn inner wall", + "segment": 187, + "svg": [ + 245.082, + 131.748 + ] + } + ] + }, + "discordReference": { + "thread": "https://discord.com/channels/1353173092649930835/1544901406522089632", + "sha256": "8c5ea50890a275b23860cd1cfe000ec50edbd662653c766c8b1936c67230dca7", + "imageSize": [ + 1920, + 1080 + ], + "landmarkPixels": [ + [ + 1284, + 400 + ], + [ + 655, + 424 + ], + [ + 929, + 870 + ], + [ + 1076, + 225 + ], + [ + 775, + 684 + ], + [ + 655, + 649 + ] + ], + "circleInitial": [ + 1111, + 696, + 158 + ], + "note": "Manual pixels at the first six named map corners, then a robust fit to bright neutral pixels in a narrow annulus. Screenshot is a visual check, not a gameplay distance measurement." + } +} diff --git a/test/folder_icon_registry_test.dart b/test/folder_icon_registry_test.dart index 1f453d03..568d6b9c 100644 --- a/test/folder_icon_registry_test.dart +++ b/test/folder_icon_registry_test.dart @@ -7,8 +7,9 @@ import 'package:icarus/services/archive_manifest.dart'; import 'package:icarus/providers/folder_provider.dart'; void main() { - test('folder icon registry migration version matches app version', () { - expect(folderIconRegistryVersion, Settings.versionNumber); + test('folder icon registry migration remains version 97', () { + expect(folderIconRegistryVersion, 97); + expect(folderIconRegistryVersion, lessThanOrEqualTo(Settings.versionNumber)); }); test('folder icon registry ids are unique and picker-safe', () { diff --git a/test/sunset_scale_migration_test.dart b/test/sunset_scale_migration_test.dart new file mode 100644 index 00000000..84123fb2 --- /dev/null +++ b/test/sunset_scale_migration_test.dart @@ -0,0 +1,358 @@ +import 'dart:convert'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/migrations/ability_scale_migration.dart'; +import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +const _oldScale = 0.9502102049421427; +const _newScale = 1.06; +const _virtualToWorld = 1000 / 831; + +Offset _abilityAnchor(PlacedAbility ability, double scale) => + ability.data.abilityData!.getAnchorPoint( + mapScale: scale, + abilitySize: Settings.abilitySize, + ) * + _virtualToWorld; + +Offset _utilityAnchor(PlacedUtility utility, double scale) => + UtilityData.utilityWidgets[utility.type]!.getAnchorPoint( + id: utility.id, + mapScale: scale, + agentSize: Settings.agentSize, + abilitySize: Settings.abilitySize, + length: utility.length, + rotation: utility.rotation, + diameterMeters: utility.customDiameter, + widthMeters: utility.customWidth, + rectLengthMeters: utility.customLength, + ) * + _virtualToWorld; + +void _expectPoint(Offset actual, Offset expected) { + expect(actual.dx, closeTo(expected.dx, 1e-7)); + expect(actual.dy, closeTo(expected.dy, 1e-7)); +} + +StrategyPage _page(bool attack) { + final abilities = [ + for (final agent in AgentData.agents.values) + for (final info in agent.abilities) + if (info.abilityData != null) + PlacedAbility( + id: '${info.type.name}-${info.index}', + data: info, + position: const Offset(610, 340), + rotation: 0.7, + length: 80, + isAlly: false, + ), + ]; + abilities.last.isDeleted = true; + return StrategyPage( + id: attack ? 'attack' : 'defense', + name: 'Saved setup', + sortIndex: attack ? 0 : 1, + isAutoNamed: false, + isAttack: attack, + settings: StrategySettings(), + drawingData: const [], + agentData: [ + PlacedAgent( + id: 'agent', + type: AgentType.veto, + position: const Offset(340, 210), + ), + ], + abilityData: abilities, + utilityData: [ + for (final type in UtilityType.values) + PlacedUtility( + id: type.name, + type: type, + position: const Offset(410, 270), + customDiameter: 14, + customWidth: 6, + customLength: 18, + ) + ..rotation = 0.6 + ..length = 80 + ..isDeleted = type == UtilityType.customRectangle, + ], + textData: const [], + imageData: const [], + lineUpGroups: [ + LineUpGroup( + id: 'group', + agent: PlacedAgent( + id: 'lineup-agent', + type: AgentType.veto, + position: const Offset(340, 210), + ), + items: [ + LineUpItem( + id: 'item', + ability: abilities.first, + notes: 'Keep this note', + youtubeLink: 'https://example.com/reference', + images: [SimpleImageData(id: 'reference', fileExtension: '.png')], + ), + ], + ), + ], + ); +} + +StrategyData _strategy({ + int version = 97, + MapValue map = MapValue.sunset, + List? pages, +}) => + StrategyData( + id: 'sunset', + name: 'Range regression', + mapData: map, + versionNumber: version, + lastEdited: DateTime.utc(2026, 1, 1), + folderID: 'folder', + pages: pages ?? [_page(true), _page(false)], + ); + +void _expectAnchors(StrategyPage before, StrategyPage after) { + expect(after.id, before.id); + expect(after.name, before.name); + expect(after.isAutoNamed, before.isAutoNamed); + expect(after.isAttack, before.isAttack); + expect(after.sortIndex, before.sortIndex); + expect(after.agentData.single.toJson(), before.agentData.single.toJson()); + for (var i = 0; i < before.abilityData.length; i++) { + final old = before.abilityData[i]; + final next = after.abilityData[i]; + _expectPoint( + next.position + _abilityAnchor(next, _newScale), + old.position + _abilityAnchor(old, _oldScale), + ); + expect(next.toJson()..remove('position'), old.toJson()..remove('position')); + } + for (var i = 0; i < before.utilityData.length; i++) { + final old = before.utilityData[i]; + final next = after.utilityData[i]; + _expectPoint( + next.position + _utilityAnchor(next, _newScale), + old.position + _utilityAnchor(old, _oldScale), + ); + expect(next.toJson()..remove('position'), old.toJson()..remove('position')); + } + final oldGroup = before.lineUpGroups.single; + final nextGroup = after.lineUpGroups.single; + expect(nextGroup.id, oldGroup.id); + expect(nextGroup.agent.toJson(), oldGroup.agent.toJson()); + final oldItem = oldGroup.items.single; + final nextItem = nextGroup.items.single; + _expectPoint( + nextItem.ability.position + _abilityAnchor(nextItem.ability, _newScale), + oldItem.ability.position + _abilityAnchor(oldItem.ability, _oldScale), + ); + expect( + jsonDecode(jsonEncode(nextItem.toJson()..remove('ability'))), + jsonDecode(jsonEncode(oldItem.toJson()..remove('ability'))), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('preserves every ability and utility anchor on both sides', () { + final source = _strategy(); + final before = jsonEncode( + source.pages.map((p) => p.toJson(source.id)).toList(), + ); + final result = StrategyProvider.migrateToCurrentVersion(source); + expect(Maps.mapScale[MapValue.sunset], _newScale); + expect(result.versionNumber, Settings.versionNumber); + expect(result.folderID, source.folderID); + expect(result.createdAt, source.createdAt); + for (var i = 0; i < source.pages.length; i++) { + _expectAnchors(source.pages[i], result.pages[i]); + } + expect( + jsonEncode(source.pages.map((p) => p.toJson(source.id)).toList()), + before, + ); + expect( + identical(StrategyProvider.migrateToCurrentVersion(result), result), + isTrue, + ); + }); + + test('other maps keep their pages unchanged', () { + final source = _strategy(map: MapValue.split); + final result = StrategyProvider.migrateToCurrentVersion(source); + expect(identical(result.pages, source.pages), isTrue); + }); + + test('v96 defense preserves the reflected historical Crosscut center', () { + final source = _strategy(version: 96, pages: [_page(false)]); + final before = source.pages.single.abilityData + .firstWhere((a) => a.data.name == 'Crosscut'); + final result = StrategyProvider.migrateToCurrentVersion(source); + final after = + result.pages.single.abilityData.firstWhere((a) => a.id == before.id); + final oldCenter = before.position + + const Offset(1, 1) * (30 * 5.78 * _oldScale * _virtualToWorld); + _expectPoint(after.position + _abilityAnchor(after, _newScale), + Offset(1000 * 16 / 9 - oldCenter.dx, 1000 - oldCenter.dy)); + }); + + test('version 39 and 45 retain their historical Sunset scale', () { + final crosscut = PlacedAbility( + id: 'crosscut', + data: AgentData.agents[AgentType.veto]!.abilities.firstWhere( + (a) => a.name == 'Crosscut', + ), + position: Offset.zero, + ); + final migrated = AbilityScaleMigration.migratePlacedAbilityPosition( + ability: crosscut, + map: MapValue.sunset, + ); + const shift = 30 * (5.5 * 1.048 - 5.78 * _oldScale); + _expectPoint(migrated.position, Offset(shift, shift)); + final source = _page(true); + final oldCircle = source.utilityData.singleWhere( + (u) => u.type == UtilityType.customCircle, + ); + final result = CustomCircleWrapperMigration.migratePages( + pages: [source], + map: MapValue.sunset, + ).single; + final circle = result.utilityData.singleWhere( + (u) => u.type == UtilityType.customCircle, + ); + const inset = (40 - 14) * 5.78 * _oldScale; + _expectPoint(circle.position, oldCircle.position - Offset(inset, inset)); + }); + + for (final version in [16, 38, 39, 44, 45, 96]) { + test( + 'v$version import completes every required migration in order', + () async { + final source = _strategy(version: version); + // Explicit historical stages provide the pre-98 placement reference. + var historical = StrategyProvider.migrateToWorld16x9(source); + if (version < 39) + historical = StrategyProvider.migrateAbilityScale( + historical, + force: true, + ); + if (version < 40) + historical = StrategyProvider.migrateSquareAoeCenter( + historical, + force: true, + ); + if (version < 45) + historical = StrategyProvider.migrateCustomCircleWrapper( + historical, + force: true, + ); + if (version < 61) + historical = StrategyProvider.migrateLineUpGroups( + historical, + force: true, + ); + if (version < 95) + historical = StrategyProvider.migrateAbilityVisionCones( + historical, + force: true, + ); + if (version < 95) + historical = StrategyProvider.migratePageNameProvenance( + historical, + force: true, + ); + historical = StrategyProvider.migrateCanonicalCoordinates( + historical, + force: true, + ); + final result = await StrategyProvider.migrateLegacyData(source); + for (var i = 0; i < historical.pages.length; i++) { + _expectAnchors(historical.pages[i], result.pages[i]); + } + }, + ); + } + + test( + 'pre-page strategies receive the correction after legacy conversion', + () async { + final page = _page(false); + final old = _strategy(version: 15, pages: []).copyWith( + abilityData: page.abilityData, + utilityData: page.utilityData, + agentData: page.agentData.cast(), + isAttack: false, + ); + final result = await StrategyProvider.migrateLegacyData(old); + final paged = await StrategyProvider.migrateLegacyData( + _strategy(version: 15, pages: [page.copyWith(lineUpGroups: [])]), + ); + expect( + result.pages.single.abilityData.map((a) => a.toJson()).toList(), + paged.pages.single.abilityData.map((a) => a.toJson()).toList(), + ); + expect( + result.pages.single.utilityData.map((u) => u.toJson()).toList(), + paged.pages.single.utilityData.map((u) => u.toJson()).toList(), + ); + expect(result.versionNumber, Settings.versionNumber); + }, + ); + + test( + 'zip JSON export/import preserves migrated placements without a second shift', + () async { + final migrated = await StrategyProvider.migrateLegacyData(_strategy()); + final exportedPages = + migrated.pages.map((p) => p.toJson(migrated.id)).toList(); + final bytes = utf8.encode( + jsonEncode({ + 'versionNumber': '${migrated.versionNumber}', + 'pages': exportedPages, + }), + ); + final archive = Archive() + ..addFile(ArchiveFile('strategy.json', bytes.length, bytes)); + final restoredArchive = ZipDecoder().decodeBytes( + ZipEncoder().encode(archive), + ); + final decoded = jsonDecode( + utf8.decode(restoredArchive.files.single.content as List), + ) as Map; + final pages = await StrategyPage.listFromJson( + json: jsonEncode(decoded['pages']), + strategyID: migrated.id, + isZip: true, + ); + final restored = await StrategyProvider.migrateLegacyData( + migrated.copyWith( + versionNumber: int.parse(decoded['versionNumber'] as String), + pages: pages, + ), + ); + expect( + restored.pages.map((p) => p.toJson(restored.id)).toList(), + exportedPages, + ); + }, + ); +}