Use canonical Eve surface publication
This commit is contained in:
parent
229070ff48
commit
f191767471
4 changed files with 324 additions and 172 deletions
|
|
@ -2,11 +2,13 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<CultLibRoot Condition="'$(CultLibRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\CultLib</CultLibRoot>
|
||||
<EveUnityRoot Condition="'$(EveUnityRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\EveUnity</EveUnityRoot>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching\GameCult.Caching.csproj" />
|
||||
<ProjectReference Include="$(CultLibRoot)\src\GameCult.Caching.MessagePack\GameCult.Caching.MessagePack.csproj" />
|
||||
<ProjectReference Include="$(EveUnityRoot)\packages\org.gamecult.eve.surface\GameCult.Eve.Surface.csproj" />
|
||||
<ProjectReference Include="..\..\..\CultMath\src\CultMath\CultMath.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
187
src/Ymir.Core/YmirServicePublicationDocument.cs
Normal file
187
src/Ymir.Core/YmirServicePublicationDocument.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Eve.Surface;
|
||||
using CultMath;
|
||||
using MessagePack;
|
||||
|
||||
namespace Ymir.Core;
|
||||
|
||||
[CultDocument("gamecult.ymir.provider_advertisement", "gamecult.ymir.provider_advertisement.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class YmirProviderAdvertisementDocument
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string ProviderId { get; set; } = "ymir.physics";
|
||||
|
||||
[Key(1)]
|
||||
public string Title { get; set; } = "Ymir Physics";
|
||||
|
||||
[Key(2)]
|
||||
public string Description { get; set; } = "GameCult physics/world substrate engine built on CultMath.";
|
||||
|
||||
[Key(3)]
|
||||
public string[] Owns { get; set; } = [];
|
||||
|
||||
[Key(4)]
|
||||
public string[] DoesNotOwn { get; set; } = [];
|
||||
|
||||
[Key(5)]
|
||||
public YmirTransportLoweringDocument[] CommandLowerings { get; set; } = [];
|
||||
|
||||
[Key(6)]
|
||||
public string UpdatedAtUtc { get; set; } = "";
|
||||
}
|
||||
|
||||
[CultDocument("gamecult.ymir.operator_state", "gamecult.ymir.operator_state.v1")]
|
||||
[MessagePackObject]
|
||||
public sealed class YmirOperatorStateDocument
|
||||
{
|
||||
[Key(0)]
|
||||
[CultName]
|
||||
public string ProviderId { get; set; } = "ymir.physics";
|
||||
|
||||
[Key(1)]
|
||||
public string Status { get; set; } = "mvp";
|
||||
|
||||
[Key(2)]
|
||||
public string StateOwner { get; set; } = "Ymir.Core step and query results";
|
||||
|
||||
[Key(3)]
|
||||
public string NumericSubstrate { get; set; } = "CultMath";
|
||||
|
||||
[Key(4)]
|
||||
public string BatchKernel { get; set; } = "";
|
||||
|
||||
[Key(5)]
|
||||
public string Persistence { get; set; } = "CultCache MessagePack directory store for gamecult.ymir.world_state.v1";
|
||||
|
||||
[Key(6)]
|
||||
public string UpdatedAtUtc { get; set; } = "";
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class YmirTransportLoweringDocument
|
||||
{
|
||||
[Key(0)]
|
||||
public string Label { get; set; } = "";
|
||||
|
||||
[Key(1)]
|
||||
public string Method { get; set; } = "POST";
|
||||
|
||||
[Key(2)]
|
||||
public string Path { get; set; } = "";
|
||||
|
||||
[Key(3)]
|
||||
public string Purpose { get; set; } = "";
|
||||
}
|
||||
|
||||
public static class YmirServicePublication
|
||||
{
|
||||
public static YmirProviderAdvertisementDocument ProviderAdvertisement(DateTimeOffset now) => new()
|
||||
{
|
||||
ProviderId = "ymir.physics",
|
||||
Title = "Ymir Physics",
|
||||
Description = "GameCult physics/world substrate engine built on CultMath.",
|
||||
Owns =
|
||||
[
|
||||
"physics simulation truth",
|
||||
"body integration",
|
||||
"field acceleration",
|
||||
"contact generation",
|
||||
"collision query semantics",
|
||||
"SoA CultCache world-state persistence"
|
||||
],
|
||||
DoesNotOwn =
|
||||
[
|
||||
"Unity scene truth",
|
||||
"rendering",
|
||||
"gameplay damage policy",
|
||||
"editor authoring truth"
|
||||
],
|
||||
CommandLowerings = [],
|
||||
UpdatedAtUtc = FormatUtc(now)
|
||||
};
|
||||
|
||||
public static YmirOperatorStateDocument OperatorState(DateTimeOffset now) => new()
|
||||
{
|
||||
ProviderId = "ymir.physics",
|
||||
Status = "mvp",
|
||||
StateOwner = "Ymir.Core step and query results",
|
||||
NumericSubstrate = "CultMath",
|
||||
BatchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}",
|
||||
Persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v1",
|
||||
UpdatedAtUtc = FormatUtc(now)
|
||||
};
|
||||
|
||||
public static EveSurfaceDocument EveSurface(YmirOperatorStateDocument diagnostics) =>
|
||||
GameCult.Eve.Surface.EveSurface.Create("ymir.physics.operator")
|
||||
.Provider(diagnostics.ProviderId, "physics.operator")
|
||||
.Title("Ymir Physics")
|
||||
.UpdatedAtUtc(diagnostics.UpdatedAtUtc)
|
||||
.RootColumn("ymir.physics.operator.root", root => root
|
||||
.Metric("ymir.physics.operator.status", "Status", diagnostics.Status)
|
||||
.Metric("ymir.physics.operator.owner", "State owner", diagnostics.StateOwner)
|
||||
.Metric("ymir.physics.operator.math", "Numeric substrate", diagnostics.NumericSubstrate)
|
||||
.Metric("ymir.physics.operator.batch", "Batch kernel", diagnostics.BatchKernel)
|
||||
.Metric("ymir.physics.operator.persistence", "Persistence", diagnostics.Persistence))
|
||||
.Build();
|
||||
|
||||
public static EveSurfaceDocument EveSurface(DateTimeOffset now)
|
||||
{
|
||||
return EveSurface(OperatorState(now));
|
||||
}
|
||||
|
||||
private static string FormatUtc(DateTimeOffset value) => value.UtcDateTime.ToString("O");
|
||||
}
|
||||
|
||||
public static class YmirServicePublicationStore
|
||||
{
|
||||
public const string SurfaceRecordKey = "surface:ymir.physics.operator";
|
||||
|
||||
public static async Task RegenerateDerivedStoreAsync(string path, DateTimeOffset now)
|
||||
{
|
||||
DeleteDerivedStore(path);
|
||||
await PublishAsync(path, now).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task PublishAsync(string path, DateTimeOffset now)
|
||||
{
|
||||
var cache = await CultCacheMessagePack.OpenAsync(
|
||||
path,
|
||||
new CultCacheOpenOptions
|
||||
{
|
||||
UseDirectoryStore = true,
|
||||
FlushOnDispose = true,
|
||||
StoreFlushOnDispose = true
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
using (cache)
|
||||
{
|
||||
var operatorState = YmirServicePublication.OperatorState(now);
|
||||
await cache.UpsertAsync(
|
||||
YmirServicePublication.ProviderAdvertisement(now),
|
||||
new CultRecordHandle<YmirProviderAdvertisementDocument>(new CultRecordKey("ymir:provider:ymir.physics")))
|
||||
.ConfigureAwait(false);
|
||||
await cache.UpsertAsync(
|
||||
operatorState,
|
||||
new CultRecordHandle<YmirOperatorStateDocument>(new CultRecordKey("ymir:operator:ymir.physics")))
|
||||
.ConfigureAwait(false);
|
||||
await cache.UpsertAsync(
|
||||
YmirServicePublication.EveSurface(operatorState),
|
||||
new CultRecordHandle<EveSurfaceDocument>(new CultRecordKey(SurfaceRecordKey)))
|
||||
.ConfigureAwait(false);
|
||||
await cache.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteDerivedStore(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
|
||||
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path);
|
||||
if (Directory.Exists(recordsPath))
|
||||
Directory.Delete(recordsPath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CultMath;
|
||||
using Ymir.Core;
|
||||
|
|
@ -19,11 +17,8 @@ internal static class Program
|
|||
|
||||
switch (command)
|
||||
{
|
||||
case "provider":
|
||||
WriteJson(ProviderAdvertisement());
|
||||
return 0;
|
||||
case "operator-state":
|
||||
WriteJson(OperatorState());
|
||||
case "publish-service":
|
||||
await PublishServiceAsync(ValueAfter(args, "--path") ?? ".ymir/service.cc");
|
||||
return 0;
|
||||
case "step-sample":
|
||||
WriteJson(new YmirSimulator().Step(SampleRequest()));
|
||||
|
|
@ -33,9 +28,6 @@ internal static class Program
|
|||
return 0;
|
||||
case "step":
|
||||
return RunStep(args);
|
||||
case "serve":
|
||||
await ServeAsync(ParsePort(args, 8877));
|
||||
return 0;
|
||||
default:
|
||||
PrintHelp();
|
||||
return command is "help" or "-h" or "--help" ? 0 : 1;
|
||||
|
|
@ -65,146 +57,6 @@ internal static class Program
|
|||
return 0;
|
||||
}
|
||||
|
||||
private static async Task ServeAsync(int port)
|
||||
{
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add($"http://127.0.0.1:{port}/");
|
||||
listener.Start();
|
||||
Console.WriteLine($"Ymir serving on http://127.0.0.1:{port}/");
|
||||
|
||||
while (true)
|
||||
{
|
||||
var context = await listener.GetContextAsync();
|
||||
_ = Task.Run(() => HandleRequestAsync(context));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = context.Request.Url?.AbsolutePath ?? "/";
|
||||
var method = context.Request.HttpMethod;
|
||||
|
||||
if (method == "GET" && path == "/health")
|
||||
{
|
||||
await WriteResponseAsync(context, new { ok = true, service = "ymir", checkedAt = DateTimeOffset.UtcNow });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/provider-advertisement")
|
||||
{
|
||||
await WriteResponseAsync(context, ProviderAdvertisement());
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/operator-state")
|
||||
{
|
||||
await WriteResponseAsync(context, OperatorState());
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/eve/operator")
|
||||
{
|
||||
await WriteResponseAsync(context, EveOperatorSurface());
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == "POST" && path == "/simulate/step")
|
||||
{
|
||||
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
var request = JsonSerializer.Deserialize<SimulationStepRequest>(body, JsonOptions);
|
||||
if (request is null)
|
||||
{
|
||||
await WriteErrorAsync(context, 400, "Invalid simulation request.");
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteResponseAsync(context, new YmirSimulator().Step(request));
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteErrorAsync(context, 404, "No such Ymir route.");
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
await WriteErrorAsync(context, 500, error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static object ProviderAdvertisement() => new
|
||||
{
|
||||
schema = "gamecult.provider_advertisement.v1",
|
||||
providerId = "ymir.physics",
|
||||
title = "Ymir Physics",
|
||||
description = "GameCult physics/world substrate engine built on CultMath.",
|
||||
authority = new
|
||||
{
|
||||
owns = new[]
|
||||
{
|
||||
"physics simulation truth",
|
||||
"body integration",
|
||||
"field acceleration",
|
||||
"contact generation",
|
||||
"collision query semantics",
|
||||
"SoA CultCache world-state persistence"
|
||||
},
|
||||
doesNotOwn = new[]
|
||||
{
|
||||
"Unity scene truth",
|
||||
"rendering",
|
||||
"gameplay damage policy",
|
||||
"editor authoring truth"
|
||||
}
|
||||
},
|
||||
routes = new
|
||||
{
|
||||
health = "/health",
|
||||
providerAdvertisement = "/provider-advertisement",
|
||||
operatorState = "/operator-state",
|
||||
operatorSurface = "/eve/operator",
|
||||
step = "/simulate/step"
|
||||
}
|
||||
};
|
||||
|
||||
private static object OperatorState() => new
|
||||
{
|
||||
schema = "gamecult.ymir.operator_state.v0",
|
||||
providerId = "ymir.physics",
|
||||
status = "mvp",
|
||||
stateOwner = "Ymir.Core step result; JSON is compatibility witness only",
|
||||
numericSubstrate = "CultMath",
|
||||
batchKernel = $"CultMath.BatchMath lanes={BatchMath.LaneCount}, hardwareAccelerated={BatchMath.IsHardwareAccelerated}",
|
||||
persistence = "CultCache MessagePack directory store for gamecult.ymir.world_state.v0",
|
||||
updatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
private static object EveOperatorSurface() => new
|
||||
{
|
||||
schema = "gamecult.eve.surface.v1",
|
||||
providerId = "ymir.physics",
|
||||
title = "Ymir Physics",
|
||||
updatedAt = DateTimeOffset.UtcNow,
|
||||
surface = new
|
||||
{
|
||||
root = new
|
||||
{
|
||||
type = "panel",
|
||||
title = "Ymir Physics",
|
||||
children = new object[]
|
||||
{
|
||||
new { type = "stat", label = "Status", value = "mvp" },
|
||||
new { type = "stat", label = "Owner", value = "physics simulation truth" },
|
||||
new { type = "stat", label = "Math", value = "CultMath" },
|
||||
new { type = "stat", label = "Batch lanes", value = BatchMath.LaneCount.ToString() },
|
||||
new { type = "stat", label = "Persistence", value = "CultCache SoA world_state.v0" },
|
||||
new { type = "route", label = "Step", value = "POST /simulate/step" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static SimulationStepRequest SampleRequest() => new(
|
||||
0.1f,
|
||||
new YmirWorld(
|
||||
|
|
@ -236,30 +88,24 @@ internal static class Program
|
|||
});
|
||||
}
|
||||
|
||||
private static async Task WriteResponseAsync(HttpListenerContext context, object value)
|
||||
private static async Task PublishServiceAsync(string path)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, JsonOptions);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
context.Response.ContentType = "application/json; charset=utf-8";
|
||||
context.Response.ContentLength64 = bytes.Length;
|
||||
await context.Response.OutputStream.WriteAsync(bytes);
|
||||
context.Response.Close();
|
||||
}
|
||||
|
||||
private static Task WriteErrorAsync(HttpListenerContext context, int statusCode, string message)
|
||||
{
|
||||
context.Response.StatusCode = statusCode;
|
||||
return WriteResponseAsync(context, new { ok = false, error = message });
|
||||
await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow);
|
||||
WriteJson(new
|
||||
{
|
||||
ok = true,
|
||||
path,
|
||||
records = new[]
|
||||
{
|
||||
"ymir:provider:ymir.physics",
|
||||
"ymir:operator:ymir.physics",
|
||||
"surface:ymir.physics.operator"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void WriteJson(object value) => Console.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
|
||||
|
||||
private static int ParsePort(string[] args, int defaultPort)
|
||||
{
|
||||
var raw = ValueAfter(args, "--port");
|
||||
return int.TryParse(raw, out var port) ? port : defaultPort;
|
||||
}
|
||||
|
||||
private static string? ValueAfter(string[] args, string key)
|
||||
{
|
||||
var index = Array.IndexOf(args, key);
|
||||
|
|
@ -272,12 +118,10 @@ internal static class Program
|
|||
Ymir physics daemon
|
||||
|
||||
Commands:
|
||||
provider
|
||||
operator-state
|
||||
publish-service [--path .ymir/service.cc] Regenerate the derived service publication.
|
||||
step-sample
|
||||
save-sample [--path .ymir/worlds.cc]
|
||||
step --request <path>
|
||||
serve [--port 8877]
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
tests/Ymir.Tests/YmirServicePublicationTests.cs
Normal file
119
tests/Ymir.Tests/YmirServicePublicationTests.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using GameCult.Caching;
|
||||
using GameCult.Caching.MessagePack;
|
||||
using GameCult.Eve.Surface;
|
||||
using Ymir.Core;
|
||||
|
||||
namespace Ymir.Tests;
|
||||
|
||||
public sealed class YmirServicePublicationTests
|
||||
{
|
||||
[Fact]
|
||||
public void EveSurfaceUsesCanonicalDocumentAndOperatorMetrics()
|
||||
{
|
||||
var diagnostics = new YmirOperatorStateDocument
|
||||
{
|
||||
ProviderId = "ymir.physics",
|
||||
Status = "ready",
|
||||
StateOwner = "diagnostic-owner",
|
||||
NumericSubstrate = "diagnostic-math",
|
||||
BatchKernel = "diagnostic-batch",
|
||||
Persistence = "diagnostic-persistence",
|
||||
UpdatedAtUtc = "2026-07-13T12:00:00.0000000Z"
|
||||
};
|
||||
|
||||
EveSurfaceDocument surface = YmirServicePublication.EveSurface(diagnostics);
|
||||
|
||||
Assert.Equal(EveSurfaceDocument.SchemaId, surface.Schema);
|
||||
Assert.Equal("ymir.physics.operator", surface.Surface.Id);
|
||||
Assert.Equal("partition", surface.Surface.Root.Kind);
|
||||
Assert.Equal(diagnostics.UpdatedAtUtc, surface.UpdatedAtUtc);
|
||||
Assert.Equal(
|
||||
new[]
|
||||
{
|
||||
("Status", diagnostics.Status),
|
||||
("State owner", diagnostics.StateOwner),
|
||||
("Numeric substrate", diagnostics.NumericSubstrate),
|
||||
("Batch kernel", diagnostics.BatchKernel),
|
||||
("Persistence", diagnostics.Persistence)
|
||||
},
|
||||
surface.Surface.Root.Children
|
||||
.Select(child => (child.GetProp("label"), child.GetProp("value")))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CombinedRegistryContainsExactlyOneCanonicalSurfaceDescriptor()
|
||||
{
|
||||
var registry = new CultDocumentRegistry();
|
||||
|
||||
var descriptors = registry.AllDescriptors
|
||||
.Where(descriptor => descriptor.SchemaVersion == EveSurfaceDocument.SchemaId)
|
||||
.ToArray();
|
||||
|
||||
var descriptor = Assert.Single(descriptors);
|
||||
Assert.Equal(typeof(EveSurfaceDocument), descriptor.DocumentType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublicationPersistsAndReopensWithCombinedRegistry()
|
||||
{
|
||||
var path = TemporaryStorePath();
|
||||
try
|
||||
{
|
||||
await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow);
|
||||
|
||||
using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions
|
||||
{
|
||||
Registry = new CultDocumentRegistry(),
|
||||
UseDirectoryStore = true
|
||||
});
|
||||
|
||||
var surface = cache.Get<EveSurfaceDocument>(new CultRecordKey(YmirServicePublicationStore.SurfaceRecordKey));
|
||||
Assert.NotNull(surface);
|
||||
Assert.Equal("ymir.physics.operator", surface.Surface.Id);
|
||||
Assert.Single(cache.GetAll<EveSurfaceDocument>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteStore(path);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegenerationReplacesUnreadableLegacyDerivedStoreBeforeOpen()
|
||||
{
|
||||
var path = TemporaryStorePath();
|
||||
var recordsPath = DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
Directory.CreateDirectory(recordsPath);
|
||||
await File.WriteAllBytesAsync(path, [0xc1]);
|
||||
await File.WriteAllBytesAsync(Path.Combine(recordsPath, "legacy.msgpack"), [0xc1]);
|
||||
|
||||
await YmirServicePublicationStore.RegenerateDerivedStoreAsync(path, DateTimeOffset.UtcNow);
|
||||
|
||||
using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions
|
||||
{
|
||||
Registry = new CultDocumentRegistry(),
|
||||
UseDirectoryStore = true
|
||||
});
|
||||
Assert.NotNull(cache.Get<EveSurfaceDocument>(new CultRecordKey(YmirServicePublicationStore.SurfaceRecordKey)));
|
||||
Assert.False(File.Exists(Path.Combine(recordsPath, "legacy.msgpack")));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteStore(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TemporaryStorePath() =>
|
||||
Path.Combine(Path.GetTempPath(), "ymir-tests", Guid.NewGuid().ToString("N"), "service.cc");
|
||||
|
||||
private static void DeleteStore(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (directory is not null && Directory.Exists(directory))
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue