Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Unity/Assets/_SecondSpawn/Scripts/AI/AgentContextDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
60 changes: 45 additions & 15 deletions backend/nakama/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
};
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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
};
Expand Down Expand Up @@ -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");
}

Expand All @@ -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;
Expand Down
75 changes: 74 additions & 1 deletion backend/nakama/tests/supabase_custom_auth.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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: {} };
Expand Down
12 changes: 8 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions docs/design/23-gameplay-ledger-technical-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading