diff --git a/CHANGELOG.md b/CHANGELOG.md index d087a4b..5086703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ versioned release tag yet, so entries are organized as pre-alpha snapshots. ### Added +- Prototype combat training hit responses now return a server-generated + `combat_event_id` for validated hits, reject unknown targets as structured + misses, and audit the finishing hit before the BodyTime reward event. - Character animation action registry layer: gameplay and AI prototype code can now request semantic `CharacterActionId` values such as talk, attack, hit-react, death, gather, and interact while the animation registry maps them diff --git a/ROADMAP.md b/ROADMAP.md index 5496c1c..e7852c7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -234,6 +234,10 @@ Recommended views: - [x] Prototype combat hit requests now carry impact-time range, cone, and obstacle validation data, and Nakama rejects invalid training hits without HP or reward mutation, including spoofed negative or zero validation values. +- [x] Prototype combat hit responses now include a server-generated combat + event id for validated hits, structured miss reasons for unknown, blocked, + out-of-range, outside-cone, and rebuilding targets, and combat ledger rows for + every accepted hit including the finishing strike. - [x] Prototype combat training target visuals now keep a dead target instance through its respawn window instead of spawning duplicates. - [x] Nakama has the first server-owned Relay Yard facility action lane: diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs index c1cf8b6..e9393c7 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs @@ -437,6 +437,7 @@ public sealed class CombatTrainingHitResponseDto public bool reward_claimed; public bool hit_validated; public string miss_reason; + public string combat_event_id; public string summary; public AgentContextDto context; } diff --git a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs index 640ba0c..ed5e891 100644 --- a/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs +++ b/Unity/Assets/_SecondSpawn/Scripts/AI/CharacterMemorySync.cs @@ -382,7 +382,7 @@ public IEnumerator HitPrototypeTrainingTarget( _context = response.context; onResult?.Invoke(response); - Debug.Log($"[CharacterMemorySync] Combat training hit: {response.summary} hp={response.target_hp}/{response.target_max_hp}, reward={response.reward_claimed}"); + Debug.Log($"[CharacterMemorySync] Combat training hit: {response.summary} hp={response.target_hp}/{response.target_max_hp}, reward={response.reward_claimed}, event={response.combat_event_id}"); yield return ApplyProfileToLocalPlayerWhenAvailable(); } diff --git a/backend/nakama/modules/index.ts b/backend/nakama/modules/index.ts index 457b57f..630a1ba 100644 --- a/backend/nakama/modules/index.ts +++ b/backend/nakama/modules/index.ts @@ -1514,6 +1514,23 @@ function applyAcceptedContestedLoot( function applyPrototypeTrainingHit(context: any, request: any, nk: nkruntime.Nakama): any { var combat = ensurePrototypeCombat(context); + var requestedTargetId = normalizePrototypeTrainingTargetId(request.target_id); + if (!isPrototypeTrainingTargetKnown(requestedTargetId)) { + return { + target_id: requestedTargetId || "unknown", + target_hp: 0, + target_max_hp: 0, + damage_applied: 0, + defeated: false, + reward_claimed: false, + hit_validated: false, + miss_reason: "unknown_target", + combat_event_id: "", + summary: "Training hit rejected: unknown target.", + changed: false + }; + } + var target = ensurePrototypeTrainingTarget(combat, request.target_id, nk); resetPrototypeTrainingTargetIfReady(target); var missReason = validatePrototypeTrainingHitRequest(request); @@ -1527,6 +1544,7 @@ function applyPrototypeTrainingHit(context: any, request: any, nk: nkruntime.Nak reward_claimed: false, hit_validated: false, miss_reason: missReason, + combat_event_id: "", summary: "Training hit rejected: " + missReason + ".", changed: false }; @@ -1542,15 +1560,31 @@ function applyPrototypeTrainingHit(context: any, request: any, nk: nkruntime.Nak reward_claimed: false, hit_validated: false, miss_reason: "target_rebuilding", + combat_event_id: "", summary: "Training target is rebuilding.", changed: false }; } var damage = prototypeTrainingDamage(context, request.damage); + var combatEventId = "combat-training-hit-" + target.target_id + "-" + nk.uuidv4(); target.hp = clampNumber(target.hp - damage, 0, target.max_hp); target.last_hit_at = new Date().toISOString(); target.hit_count = Math.floor(target.hit_count || 0) + 1; + addAgentActivity(context, { + id: combatEventId, + kind: "combat", + summary: "Hit prototype training target for " + damage + " damage.", + occurred_at: target.last_hit_at, + source: "nakama", + metrics: { + damage: damage, + target_id: target.target_id, + target_hp_after: target.hp, + defeated: target.hp <= 0 + } + }, nk); + var rewardClaimed = false; if (target.hp <= 0) { target.defeated_at = target.last_hit_at; @@ -1562,19 +1596,6 @@ function applyPrototypeTrainingHit(context: any, request: any, nk: nkruntime.Nak note: "Defeated a prototype training drone." }, nk); rewardClaimed = true; - } else { - addAgentActivity(context, { - id: "combat-training-hit-" + target.target_id + "-" + nk.uuidv4(), - kind: "combat", - summary: "Hit prototype training target for " + damage + " damage.", - occurred_at: target.last_hit_at, - source: "nakama", - metrics: { - damage: damage, - target_id: target.target_id, - target_hp_after: target.hp - } - }, nk); } return { @@ -1586,6 +1607,7 @@ function applyPrototypeTrainingHit(context: any, request: any, nk: nkruntime.Nak reward_claimed: rewardClaimed, hit_validated: true, miss_reason: "", + combat_event_id: combatEventId, summary: target.hp <= 0 ? "Training target defeated. BodyTime reward granted." : "Training hit recorded.", changed: true }; @@ -5332,8 +5354,8 @@ function ensurePrototypeCombat(context: any): any { } function ensurePrototypeTrainingTarget(combat: any, targetId: any, nk: nkruntime.Nakama): any { - var normalizedTargetId = sanitizeNakamaIdentifier(trimString(targetId), "prototype-training-drone"); - if (normalizedTargetId !== "prototype-training-drone") { + var normalizedTargetId = normalizePrototypeTrainingTargetId(targetId); + if (!isPrototypeTrainingTargetKnown(normalizedTargetId)) { throw new Error("unknown prototype combat target"); } @@ -5359,6 +5381,14 @@ function ensurePrototypeTrainingTarget(combat: any, targetId: any, nk: nkruntime return target; } +function normalizePrototypeTrainingTargetId(targetId: any): string { + return sanitizeNakamaIdentifier(trimString(targetId), "prototype-training-drone"); +} + +function isPrototypeTrainingTargetKnown(targetId: any): boolean { + return normalizePrototypeTrainingTargetId(targetId) === "prototype-training-drone"; +} + function resetPrototypeTrainingTargetIfReady(target: any): void { if (!target || !target.defeated_at) { return; diff --git a/backend/nakama/tests/supabase_custom_auth.test.mjs b/backend/nakama/tests/supabase_custom_auth.test.mjs index f08deb8..494e961 100644 --- a/backend/nakama/tests/supabase_custom_auth.test.mjs +++ b/backend/nakama/tests/supabase_custom_auth.test.mjs @@ -1205,6 +1205,27 @@ assert.equal(invalidTrainingHit.target_hp, 45); assert.equal(invalidTrainingHit.damage_applied, 0); assert.equal(invalidTrainingHit.hit_validated, false); assert.equal(invalidTrainingHit.miss_reason, "out_of_range"); +assert.equal(invalidTrainingHit.combat_event_id, ""); + +const unknownTargetTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_combat_training_hit")( + combatUserCtx, + harness.logger, + harness.nk, + JSON.stringify({ + target_id: "client-invented-target", + damage: 10, + distance_meters: 1.1, + angle_degrees: 12, + range_meters: 2.2, + half_angle_degrees: 55, + line_blocked: false + }) +)); +assert.equal(unknownTargetTrainingHit.target_id, "client-invented-target"); +assert.equal(unknownTargetTrainingHit.target_hp, 0); +assert.equal(unknownTargetTrainingHit.damage_applied, 0); +assert.equal(unknownTargetTrainingHit.hit_validated, false); +assert.equal(unknownTargetTrainingHit.miss_reason, "unknown_target"); const inflatedRangeTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_combat_training_hit")( combatUserCtx, @@ -1225,6 +1246,44 @@ assert.equal(inflatedRangeTrainingHit.damage_applied, 0); assert.equal(inflatedRangeTrainingHit.hit_validated, false); assert.equal(inflatedRangeTrainingHit.miss_reason, "out_of_range"); +const blockedTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_combat_training_hit")( + combatUserCtx, + harness.logger, + harness.nk, + JSON.stringify({ + target_id: "prototype-training-drone", + damage: 10, + distance_meters: 1.1, + angle_degrees: 12, + range_meters: 2.2, + half_angle_degrees: 55, + line_blocked: true + }) +)); +assert.equal(blockedTrainingHit.target_hp, 45); +assert.equal(blockedTrainingHit.damage_applied, 0); +assert.equal(blockedTrainingHit.hit_validated, false); +assert.equal(blockedTrainingHit.miss_reason, "blocked"); + +const outsideConeTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_combat_training_hit")( + combatUserCtx, + harness.logger, + harness.nk, + JSON.stringify({ + target_id: "prototype-training-drone", + damage: 10, + distance_meters: 1.1, + angle_degrees: 80, + range_meters: 2.2, + half_angle_degrees: 55, + line_blocked: false + }) +)); +assert.equal(outsideConeTrainingHit.target_hp, 45); +assert.equal(outsideConeTrainingHit.damage_applied, 0); +assert.equal(outsideConeTrainingHit.hit_validated, false); +assert.equal(outsideConeTrainingHit.miss_reason, "outside_cone"); + const negativeDistanceTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_combat_training_hit")( combatUserCtx, harness.logger, @@ -1273,6 +1332,7 @@ assert.equal(firstTrainingHit.target_hp, 35); assert.equal(firstTrainingHit.damage_applied, 10); assert.equal(firstTrainingHit.reward_claimed, false); assert.equal(firstTrainingHit.hit_validated, true); +assert.match(firstTrainingHit.combat_event_id, /^combat-training-hit-prototype-training-drone-/); const finishingTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_combat_training_hit")( combatUserCtx, @@ -1301,7 +1361,20 @@ const fourthTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_com )); assert.equal(fourthTrainingHit.target_hp, 0); assert.equal(fourthTrainingHit.reward_claimed, true); +assert.equal(fourthTrainingHit.hit_validated, true); +assert.match(fourthTrainingHit.combat_event_id, /^combat-training-hit-prototype-training-drone-/); assert.match(fourthTrainingHit.context.body.agent_activity[0].summary, /training drone/); +const defeatedTrainingHit = JSON.parse(harness.registeredRpcs.get("secondspawn_combat_training_hit")( + combatUserCtx, + harness.logger, + harness.nk, + validTrainingHit(10) +)); +assert.equal(defeatedTrainingHit.target_hp, 0); +assert.equal(defeatedTrainingHit.damage_applied, 0); +assert.equal(defeatedTrainingHit.reward_claimed, false); +assert.equal(defeatedTrainingHit.hit_validated, false); +assert.equal(defeatedTrainingHit.miss_reason, "target_rebuilding"); const combatLedger = JSON.parse(harness.registeredRpcs.get("secondspawn_gameplay_ledger_list")( combatUserCtx, harness.logger, @@ -1312,7 +1385,7 @@ assert.equal(combatLedger.entries[0].category, "reward"); assert.equal(combatLedger.entries[0].reward_id, "prototype-training-drone"); assert.equal(combatLedger.entries[1].category, "combat"); assert.equal(combatLedger.entries[1].target_id, "prototype-training-drone"); -assert.equal(combatLedger.entries.filter((entry) => entry.category === "combat").length, 3); +assert.equal(combatLedger.entries.filter((entry) => entry.category === "combat").length, 4); const lootHarness = createRuntimeHarness(module); const attackerCtx = { userId: "loot-attacker", env: {} }; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index faa59f4..e91b693 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -397,10 +397,14 @@ target plate displays name and HP, successful validated hits show floating damag misses show `MISS`, and target HP comes from Nakama's combat training response. The prototype request carries impact-time range, cone, and obstacle validation data, and Nakama rejects invalid training hits without mutating HP or reward -state. Nakama caps accepted prototype range and cone values server-side so the -client cannot inflate its own hit envelope. This is still only a presentation -harness. Real enemies should be Fusion objects with server-owned hit queries, -damage, death, respawn, rewards, and loot. +state. Unknown targets, blocked line checks, out-of-range swings, outside-cone +swings, and rebuilding targets return structured miss reasons instead of +granting local effects. Nakama caps accepted prototype range and cone values +server-side so the client cannot inflate its own hit envelope. Validated hits +return a `combat_event_id` and write combat ledger rows, including the finishing +hit before any BodyTime reward event. This is still only a presentation harness. +Real enemies should be Fusion objects with server-owned hit queries, damage, +death, respawn, rewards, and loot. Contested loot follows the same boundary. The prototype `secondspawn_contested_loot_claim` RPC requires a validated combat event id, diff --git a/docs/design/23-gameplay-ledger-technical-design.md b/docs/design/23-gameplay-ledger-technical-design.md index b8f38e3..99e7340 100644 --- a/docs/design/23-gameplay-ledger-technical-design.md +++ b/docs/design/23-gameplay-ledger-technical-design.md @@ -101,6 +101,9 @@ GameplayLedgerEntry mutations, dungeon clears, and admin corrections need stricter retention and privacy rules. The first contested loot rule only records a capped transfer after a validated combat event id and positional validation are present. +- Prototype combat training hits use the combat ledger entry id as the first + validated `combat_event_id`. The finishing hit is recorded before the reward + row so later systems can reference the attack that caused the payout. ## Testing Strategy