NEO-138: quest-fixture resetFactionIds for gate deny Bruno.
Clear prototype faction standing in seq 10 pre-request so stale +15 from prior seq 11 runs does not pass the grid contract gate locally.pull/178/head
parent
2581677dd5
commit
4ee29e867b
|
|
@ -6,7 +6,7 @@ meta {
|
||||||
|
|
||||||
docs {
|
docs {
|
||||||
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15.
|
NEO-137: prototype_quest_grid_contract requires completed operator chain and Grid Operators standing >= 15.
|
||||||
Pre-request resets grid contract progress (stale completed rows from prior local seq 11 runs), then marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing stays 0).
|
Pre-request resets grid contract progress and prototype faction standing (stale state from prior seq 11 runs), then marks operator chain completed via POST …/__dev/quest-fixture (no rep delivery; standing 0).
|
||||||
Success sibling: seq 11 Accept grid contract after operator chain (NEO-138 organic rep grant).
|
Success sibling: seq 11 Accept grid contract after operator chain (NEO-138 organic rep grant).
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,12 +17,17 @@ script:pre-request {
|
||||||
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
const jsonHeaders = { headers: { "Content-Type": "application/json" } };
|
||||||
const chainQuestId = "prototype_quest_operator_chain";
|
const chainQuestId = "prototype_quest_operator_chain";
|
||||||
const gridContractQuestId = "prototype_quest_grid_contract";
|
const gridContractQuestId = "prototype_quest_grid_contract";
|
||||||
|
const prototypeFactionIds = [
|
||||||
|
"prototype_faction_grid_operators",
|
||||||
|
"prototype_faction_rust_collective",
|
||||||
|
];
|
||||||
|
|
||||||
const fixture = await axios.post(
|
const fixture = await axios.post(
|
||||||
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
|
`${baseUrl}/game/players/${playerId}/__dev/quest-fixture`,
|
||||||
{
|
{
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
resetQuestIds: [gridContractQuestId],
|
resetQuestIds: [gridContractQuestId],
|
||||||
|
resetFactionIds: prototypeFactionIds,
|
||||||
completedQuestIds: [chainQuestId],
|
completedQuestIds: [chainQuestId],
|
||||||
},
|
},
|
||||||
{ ...jsonHeaders, validateStatus: () => true },
|
{ ...jsonHeaders, validateStatus: () => true },
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,20 @@ public sealed class InMemoryFactionStandingStoreTests
|
||||||
Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
Assert.Equal(85, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryClearStanding_ShouldReturnZeroRead_WhenRowRemoved()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var store = CreateStore();
|
||||||
|
store.SeedRawStandingForTests(PlayerId, GridFactionId, 15);
|
||||||
|
Assert.Equal(15, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
// Act
|
||||||
|
var cleared = store.TryClearStanding(PlayerId, GridFactionId);
|
||||||
|
// Assert
|
||||||
|
Assert.True(cleared);
|
||||||
|
Assert.Equal(0, store.TryGetStanding(PlayerId, GridFactionId).Standing);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown()
|
public void TryGetStanding_ShouldPreferUnknownFaction_WhenPlayerNotWritableAndFactionUnknown()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Net.Http.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.Quests;
|
using NeonSprawl.Server.Game.Quests;
|
||||||
using NeonSprawl.Server.Tests;
|
using NeonSprawl.Server.Tests;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
@ -188,4 +189,39 @@ public sealed class QuestFixtureApiTests
|
||||||
Assert.NotNull(body);
|
Assert.NotNull(body);
|
||||||
Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status));
|
Assert.All(body!.Quests, row => Assert.Equal(QuestProgressApi.StatusNotStarted, row.Status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PostQuestFixture_ShouldClearFactionStanding_WhenResetFactionIdsProvided()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var factory = new InMemoryWebApplicationFactory();
|
||||||
|
var client = factory.CreateClient();
|
||||||
|
var standingStore = factory.Services.GetRequiredService<IFactionStandingStore>();
|
||||||
|
const string gridFactionId = PrototypeE7M3QuestFactionRules.GridContractGateFactionId;
|
||||||
|
var apply = ReputationOperations.TryApplyDelta(
|
||||||
|
DevPlayer,
|
||||||
|
gridFactionId,
|
||||||
|
15,
|
||||||
|
"fixture-seed-standing",
|
||||||
|
ReputationDeltaSourceKinds.QuestCompletion,
|
||||||
|
ChainQuestId,
|
||||||
|
standingStore,
|
||||||
|
factory.Services.GetRequiredService<IReputationDeltaStore>(),
|
||||||
|
factory.Services.GetRequiredService<TimeProvider>());
|
||||||
|
Assert.True(apply.Success);
|
||||||
|
Assert.Equal(15, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var reset = await client.PostAsJsonAsync(
|
||||||
|
FixturePath,
|
||||||
|
new QuestFixtureRequest
|
||||||
|
{
|
||||||
|
SchemaVersion = QuestFixtureRequest.CurrentSchemaVersion,
|
||||||
|
ResetFactionIds = [gridFactionId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.OK, reset.StatusCode);
|
||||||
|
Assert.Equal(0, standingStore.TryGetStanding(DevPlayer, gridFactionId).Standing);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,10 @@ public interface IFactionStandingStore
|
||||||
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
|
/// Does not append audit rows — <see cref="IReputationDeltaStore"/> is orchestrated by NEO-136.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
|
FactionStandingMutationOutcome TryApplyStandingDelta(string playerId, string factionId, int deltaAmount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes persisted standing for one faction (missing row reads as neutral <c>0</c>).
|
||||||
|
/// Dev fixture / Bruno reset only — not for gameplay rollback.
|
||||||
|
/// </summary>
|
||||||
|
bool TryClearStanding(string playerId, string factionId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,35 @@ public sealed class InMemoryFactionStandingStore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryClearStanding(string playerId, string factionId)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||||
|
if (player.Length == 0 || faction.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!factionRegistry.TryGetDefinition(faction, out _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CanWritePlayer(player))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = FactionStandingIds.MakeStandingKey(player, faction);
|
||||||
|
lock (keyLocks.GetOrAdd(key, _ => new object()))
|
||||||
|
{
|
||||||
|
standingByKey.TryRemove(key, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
|
private static FactionStandingReadOutcome DenyRead(string reasonCode) =>
|
||||||
new(false, reasonCode, 0);
|
new(false, reasonCode, 0);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,40 @@ public sealed class PostgresFactionStandingStore(
|
||||||
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
|
return new FactionStandingMutationOutcome(true, null, previousStanding, newStanding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryClearStanding(string playerId, string factionId)
|
||||||
|
{
|
||||||
|
var player = FactionStandingIds.NormalizePlayerId(playerId);
|
||||||
|
var faction = FactionStandingIds.NormalizeFactionId(factionId);
|
||||||
|
if (player.Length == 0 || faction.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!factionRegistry.TryGetDefinition(faction, out _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostgresPlayerFactionStandingBootstrap.EnsureSchema(dataSource);
|
||||||
|
using var conn = dataSource.OpenConnection();
|
||||||
|
if (!PlayerExists(conn, player))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
DELETE FROM player_faction_standing
|
||||||
|
WHERE player_id = @pid AND faction_id = @fid;
|
||||||
|
""",
|
||||||
|
conn);
|
||||||
|
cmd.Parameters.AddWithValue("pid", player);
|
||||||
|
cmd.Parameters.AddWithValue("fid", faction);
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
|
private static bool PlayerExists(Npgsql.NpgsqlConnection conn, string playerIdNormalized, Npgsql.NpgsqlTransaction? tx = null)
|
||||||
{
|
{
|
||||||
using var cmd = new Npgsql.NpgsqlCommand(
|
using var cmd = new Npgsql.NpgsqlCommand(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
using NeonSprawl.Server.Game.PositionState;
|
using NeonSprawl.Server.Game.PositionState;
|
||||||
|
|
||||||
namespace NeonSprawl.Server.Game.Quests;
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
@ -10,7 +11,8 @@ public static class QuestFixtureApi
|
||||||
app.MapPost(
|
app.MapPost(
|
||||||
"/game/players/{id}/__dev/quest-fixture",
|
"/game/players/{id}/__dev/quest-fixture",
|
||||||
(string id, QuestFixtureRequest? body, IPositionStateStore positions,
|
(string id, QuestFixtureRequest? body, IPositionStateStore positions,
|
||||||
IQuestDefinitionRegistry questRegistry, IPlayerQuestStateStore progressStore,
|
IQuestDefinitionRegistry questRegistry, IFactionDefinitionRegistry factionRegistry,
|
||||||
|
IPlayerQuestStateStore progressStore, IFactionStandingStore standingStore,
|
||||||
TimeProvider timeProvider) =>
|
TimeProvider timeProvider) =>
|
||||||
{
|
{
|
||||||
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
|
if (body is null || body.SchemaVersion != QuestFixtureRequest.CurrentSchemaVersion)
|
||||||
|
|
@ -28,7 +30,9 @@ public static class QuestFixtureApi
|
||||||
trimmedId,
|
trimmedId,
|
||||||
body,
|
body,
|
||||||
questRegistry,
|
questRegistry,
|
||||||
|
factionRegistry,
|
||||||
progressStore,
|
progressStore,
|
||||||
|
standingStore,
|
||||||
timeProvider))
|
timeProvider))
|
||||||
{
|
{
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ public sealed class QuestFixtureRequest
|
||||||
/// <summary>Quest ids to clear from player progress (dev Bruno reset).</summary>
|
/// <summary>Quest ids to clear from player progress (dev Bruno reset).</summary>
|
||||||
[JsonPropertyName("resetQuestIds")]
|
[JsonPropertyName("resetQuestIds")]
|
||||||
public IReadOnlyList<string> ResetQuestIds { get; init; } = [];
|
public IReadOnlyList<string> ResetQuestIds { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Faction ids to clear from player standing (dev Bruno reset; missing row reads as 0).</summary>
|
||||||
|
[JsonPropertyName("resetFactionIds")]
|
||||||
|
public IReadOnlyList<string> ResetFactionIds { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>POST response for quest fixture apply.</summary>
|
/// <summary>POST response for quest fixture apply.</summary>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
namespace NeonSprawl.Server.Game.Quests;
|
namespace NeonSprawl.Server.Game.Quests;
|
||||||
|
|
||||||
|
using NeonSprawl.Server.Game.Factions;
|
||||||
|
|
||||||
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
|
/// <summary>Applies dev-only quest progress fixture writes (NEO-137 Bruno/manual QA).</summary>
|
||||||
public static class QuestFixtureOperations
|
public static class QuestFixtureOperations
|
||||||
{
|
{
|
||||||
|
|
@ -11,10 +13,14 @@ public static class QuestFixtureOperations
|
||||||
string playerId,
|
string playerId,
|
||||||
QuestFixtureRequest body,
|
QuestFixtureRequest body,
|
||||||
IQuestDefinitionRegistry questRegistry,
|
IQuestDefinitionRegistry questRegistry,
|
||||||
|
IFactionDefinitionRegistry factionRegistry,
|
||||||
IPlayerQuestStateStore progressStore,
|
IPlayerQuestStateStore progressStore,
|
||||||
|
IFactionStandingStore standingStore,
|
||||||
TimeProvider timeProvider)
|
TimeProvider timeProvider)
|
||||||
{
|
{
|
||||||
if (body.CompletedQuestIds.Count == 0 && body.ResetQuestIds.Count == 0)
|
if (body.CompletedQuestIds.Count == 0 &&
|
||||||
|
body.ResetQuestIds.Count == 0 &&
|
||||||
|
body.ResetFactionIds.Count == 0)
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -37,6 +43,19 @@ public static class QuestFixtureOperations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var factionId in body.ResetFactionIds)
|
||||||
|
{
|
||||||
|
if (!factionRegistry.TryGetDefinition(factionId, out var definition) || definition is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!standingStore.TryClearStanding(playerId, definition.Id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var questId in body.CompletedQuestIds)
|
foreach (var questId in body.CompletedQuestIds)
|
||||||
{
|
{
|
||||||
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId))
|
||||||
|
|
|
||||||
|
|
@ -104,15 +104,16 @@ Quest accept evaluates **`FactionGateRule`** rows through **`FactionGateOperatio
|
||||||
|
|
||||||
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
|
**Deny reason code:** **`faction_gate_blocked`** on **`QuestStateReasonCodes`** when any gate fails. Accept HTTP response returns **`accepted: false`** with that **`reasonCode`** (HTTP 200, same as other structured accept denies).
|
||||||
|
|
||||||
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract (clears stale **`completed`** from prior seq **11** runs) plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept.
|
**Prototype quest:** **`prototype_quest_grid_contract`** requires **`prototype_faction_grid_operators`** standing **≥ 15** after **`prototype_quest_operator_chain`** prerequisite is complete. **Deny Bruno** (`Accept grid contract faction gate deny.bru`, seq **10**) pre-requests **`POST …/__dev/quest-fixture`** with **`resetQuestIds`** for grid contract, **`resetFactionIds`** for both prototype factions (clears stale standing from prior seq **11** runs), plus **`completedQuestIds`** for operator chain — no reward delivery; standing **0**. **Success Bruno** (seq **11**, NEO-138): organic operator-chain flow then accept.
|
||||||
|
|
||||||
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
|
Plan: [NEO-137 implementation plan](../../docs/plans/NEO-137-implementation-plan.md).
|
||||||
|
|
||||||
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional:
|
**Dev quest fixture (NEO-137, Bruno/manual QA):** When `Game:EnableQuestFixtureApi` is **true** (default in **Development** via `appsettings.Development.json`, **Testing**, or CI Bruno step) or the host environment is **Development** / **Testing**, **`POST /game/players/{id}/__dev/quest-fixture`** accepts `schemaVersion` **1** with optional (resets run before **`completedQuestIds`** when combined):
|
||||||
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged). Idempotent when a quest is already completed.
|
- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent).
|
||||||
- **`resetQuestIds`** — deletes each quest's progress row for the player (idempotent when absent). Can be combined with **`completedQuestIds`** in one request (resets run first).
|
- **`resetFactionIds`** — clears each faction's standing row for the player (missing row reads as **0**; idempotent when absent).
|
||||||
|
- **`completedQuestIds`** — marks each quest **completed** via store activate + mark-complete **without reward delivery** (standing unchanged unless **`resetFactionIds`** also sent). Idempotent when a quest is already completed.
|
||||||
|
|
||||||
**400** for bad schema; **404** when disabled, player unknown, unknown quest id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` ( **`completedQuestIds`** ); order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
|
**400** for bad schema; **404** when disabled, player unknown, unknown quest/faction id, or store write fails. Not for production. Bruno: `quest-progress/Accept grid contract faction gate deny.bru` uses **`resetQuestIds`**, **`resetFactionIds`**, and **`completedQuestIds`**; order-independent accept/gather tests use **`resetQuestIds`** via **`scripts/bruno-dev-fixture-helper.js`**.
|
||||||
|
|
||||||
**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**.
|
**Bruno self-reset helpers:** `bruno/neon-sprawl-server/scripts/bruno-dev-fixture-helper.js` centralizes quest progress reset, resource-node restore, inventory clear, and post-reset verification (`resetAllPrototypeQuestProgress`, `resetGatherIntroSpine`, `resetPrototypeResourceNodes`, `clearInventory`). Combat HP/encounter reset remains **`scripts/combat-targets-reset-helper.js`**.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue