|
| 1 | +import { describe, it, expect } from "vitest"; |
| 2 | +import { concatHex, numberToHex, type Hex } from "viem"; |
| 3 | +import { resourceToHex } from "@latticexyz/common"; |
| 4 | +import type { StorageAdapterLog } from "@latticexyz/store-sync"; |
| 5 | +import { emptyCaches, extractNotifEvents, unpackIndices } from "./notificationEventProjector"; |
| 6 | + |
| 7 | +const CORE = resourceToHex({ type: "table", namespace: "app", name: "TaruchiCore" }); |
| 8 | +const STATUS = resourceToHex({ type: "table", namespace: "app", name: "TaruchiStatus" }); |
| 9 | +const DUEL = resourceToHex({ type: "table", namespace: "app", name: "Duel" }); |
| 10 | +const TOURNEY = resourceToHex({ type: "table", namespace: "app", name: "Tourney" }); |
| 11 | +const TOURNEY_RESULT = resourceToHex({ type: "offchainTable", namespace: "app", name: "TourneyResult" }); |
| 12 | + |
| 13 | +const OWNER_A = ("0x" + "a".repeat(40)) as Hex; |
| 14 | +const OWNER_B = ("0x" + "b".repeat(40)) as Hex; |
| 15 | + |
| 16 | +const IDLE = 1; |
| 17 | + |
| 18 | +function mkSetRecord(tableId: Hex, id: bigint, staticData: Hex): StorageAdapterLog { |
| 19 | + return { |
| 20 | + eventName: "Store_SetRecord", |
| 21 | + args: { tableId, keyTuple: [numberToHex(id, { size: 32 })], staticData }, |
| 22 | + } as unknown as StorageAdapterLog; |
| 23 | +} |
| 24 | + |
| 25 | +// TaruchiCore: owner address @0 (20), index u32 @20 (4) |
| 26 | +function coreLog(id: bigint, owner: Hex, index: number): StorageAdapterLog { |
| 27 | + return mkSetRecord(CORE, id, concatHex([owner, numberToHex(index, { size: 4 })])); |
| 28 | +} |
| 29 | +// TaruchiStatus: affinity u8 @0, state u8 @1, then 34 trailing bytes |
| 30 | +function statusLog(id: bigint, state: number): StorageAdapterLog { |
| 31 | + return mkSetRecord( |
| 32 | + STATUS, |
| 33 | + id, |
| 34 | + concatHex([numberToHex(0, { size: 1 }), numberToHex(state, { size: 1 }), numberToHex(0n, { size: 34 })]), |
| 35 | + ); |
| 36 | +} |
| 37 | +// Duel ENROLL SetRecord: aIdx u32 @0, bIdx u32 @4, bracket u8 @8, status u8 @9, specs u256 @10 |
| 38 | +// (resolve is a status splice we don't watch — we trigger off TourneyResult). |
| 39 | +function duelEnrollLog(id: bigint, a: number, b: number): StorageAdapterLog { |
| 40 | + return mkSetRecord( |
| 41 | + DUEL, |
| 42 | + id, |
| 43 | + concatHex([ |
| 44 | + numberToHex(a, { size: 4 }), |
| 45 | + numberToHex(b, { size: 4 }), |
| 46 | + numberToHex(1, { size: 1 }), // bracket |
| 47 | + numberToHex(1, { size: 1 }), // status = ACTIVE |
| 48 | + numberToHex(0n, { size: 32 }), // specs |
| 49 | + ]), |
| 50 | + ); |
| 51 | +} |
| 52 | +// Tourney enroll: players u256 @0, specs u256 @32, bracket u8 @64, status u8 @65 |
| 53 | +function tourneyEnrollLog(id: bigint, bracket: number, packedPlayers: bigint): StorageAdapterLog { |
| 54 | + return mkSetRecord( |
| 55 | + TOURNEY, |
| 56 | + id, |
| 57 | + concatHex([ |
| 58 | + numberToHex(packedPlayers, { size: 32 }), |
| 59 | + numberToHex(0n, { size: 32 }), |
| 60 | + numberToHex(bracket, { size: 1 }), |
| 61 | + numberToHex(1, { size: 1 }), |
| 62 | + ]), |
| 63 | + ); |
| 64 | +} |
| 65 | +// TourneyResult: the resolve signal for BOTH duels and festivals (full SetRecord). |
| 66 | +function tourneyResultLog(id: bigint): StorageAdapterLog { |
| 67 | + return mkSetRecord(TOURNEY_RESULT, id, concatHex([numberToHex(0n, { size: 32 }), numberToHex(0, { size: 4 })])); |
| 68 | +} |
| 69 | + |
| 70 | +function packPlayers(...idx: number[]): bigint { |
| 71 | + let p = 0n; |
| 72 | + idx.forEach((v, i) => { |
| 73 | + p |= BigInt(v) << (32n * BigInt(i)); |
| 74 | + }); |
| 75 | + return p; |
| 76 | +} |
| 77 | + |
| 78 | +describe("unpackIndices", () => { |
| 79 | + it("returns all non-zero indices, drops empty slots", () => { |
| 80 | + expect(unpackIndices(packPlayers(10, 20, 0, 30))).toEqual(expect.arrayContaining([10, 20, 30])); |
| 81 | + expect(unpackIndices(packPlayers(10, 20, 0, 30))).toHaveLength(3); |
| 82 | + expect(unpackIndices(0n)).toEqual([]); |
| 83 | + }); |
| 84 | +}); |
| 85 | + |
| 86 | +describe("extractNotifEvents — MINT", () => { |
| 87 | + it("fires on the FIRST TaruchiStatus write landing IDLE (the reveal)", () => { |
| 88 | + const c = emptyCaches(); |
| 89 | + // mint writes Core; reveal is the first Status write, set directly to IDLE. |
| 90 | + const ev = extractNotifEvents([coreLog(1n, OWNER_A, 10), statusLog(1n, IDLE)], c, 1); |
| 91 | + expect(ev).toEqual([{ type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" }]); |
| 92 | + }); |
| 93 | + |
| 94 | + it("does NOT re-fire on a later IDLE write (training/duel return to IDLE)", () => { |
| 95 | + const c = emptyCaches(); |
| 96 | + extractNotifEvents([coreLog(1n, OWNER_A, 10), statusLog(1n, IDLE)], c, 1); // reveal (fires) |
| 97 | + expect(extractNotifEvents([statusLog(1n, IDLE)], c, 9)).toEqual([]); // already seen |
| 98 | + }); |
| 99 | + |
| 100 | + it("skips when the owner isn't cached", () => { |
| 101 | + const c = emptyCaches(); |
| 102 | + expect(extractNotifEvents([statusLog(5n, IDLE)], c, 1)).toEqual([]); |
| 103 | + }); |
| 104 | + |
| 105 | + it("re-fires a reveal after a reorg replay (block regresses → mint-seen gate cleared)", () => { |
| 106 | + const c = emptyCaches(); |
| 107 | + extractNotifEvents([coreLog(1n, OWNER_A, 10), statusLog(1n, IDLE)], c, 100); // reveal at block 100 |
| 108 | + // reorg: indexer re-processes from an earlier block; the reveal re-lands. |
| 109 | + const ev = extractNotifEvents([statusLog(1n, IDLE)], c, 98); |
| 110 | + expect(ev).toEqual([{ type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" }]); |
| 111 | + }); |
| 112 | + |
| 113 | + it("preserves prior-state ACROSS replayed blocks — the gate clears once at the boundary, not every block", () => { |
| 114 | + const c = emptyCaches(); |
| 115 | + extractNotifEvents([coreLog(1n, OWNER_A, 10)], c, 100); // learn owner, lastBlock=100 |
| 116 | + // reorg boundary: block regresses to 98. Reveal re-lands here (first write |
| 117 | + // since the gate cleared) → mint re-fires. This is the boundary block. |
| 118 | + expect(extractNotifEvents([statusLog(1n, IDLE)], c, 98)).toEqual([ |
| 119 | + { type: "mint", recipient_wallet: OWNER_A, taruchi_id: "1" }, |
| 120 | + ]); |
| 121 | + // Next replayed block (99 > 98) climbs forward — must NOT re-clear the gate. |
| 122 | + // A training-return-to-IDLE on the already-revealed taru must stay silent. |
| 123 | + // (With the old Math.max bug, 99 < highWater(100) re-cleared → false mint.) |
| 124 | + expect(extractNotifEvents([statusLog(1n, IDLE)], c, 99)).toEqual([]); |
| 125 | + }); |
| 126 | +}); |
| 127 | + |
| 128 | +describe("extractNotifEvents — DUEL (resolves via TourneyResult, not a status splice)", () => { |
| 129 | + it("fires for both players when the duel's TourneyResult is written", () => { |
| 130 | + const c = emptyCaches(); |
| 131 | + // enroll: learn owners + duel player indices. No event yet. |
| 132 | + expect( |
| 133 | + extractNotifEvents([coreLog(100n, OWNER_A, 10), coreLog(200n, OWNER_B, 20), duelEnrollLog(999n, 10, 20)], c, 1), |
| 134 | + ).toEqual([]); |
| 135 | + // resolve: TourneyResult write for the duel id. |
| 136 | + expect(extractNotifEvents([tourneyResultLog(999n)], c, 2)).toEqual([ |
| 137 | + { type: "duel", recipient_wallet: OWNER_A, taruchi_id: "999" }, |
| 138 | + { type: "duel", recipient_wallet: OWNER_B, taruchi_id: "999" }, |
| 139 | + ]); |
| 140 | + }); |
| 141 | + |
| 142 | + it("skips a duel player whose owner isn't cached", () => { |
| 143 | + const c = emptyCaches(); |
| 144 | + c.ownerByIndex.set(10, OWNER_A); // only player 10 known |
| 145 | + extractNotifEvents([duelEnrollLog(999n, 10, 20)], c, 1); |
| 146 | + expect(extractNotifEvents([tourneyResultLog(999n)], c, 2)).toEqual([ |
| 147 | + { type: "duel", recipient_wallet: OWNER_A, taruchi_id: "999" }, |
| 148 | + ]); |
| 149 | + }); |
| 150 | +}); |
| 151 | + |
| 152 | +describe("extractNotifEvents — FESTIVAL", () => { |
| 153 | + it("notifies every entrant on the result of a festival bracket", () => { |
| 154 | + const c = emptyCaches(); |
| 155 | + extractNotifEvents([coreLog(100n, OWNER_A, 10), coreLog(200n, OWNER_B, 20)], c, 1); |
| 156 | + extractNotifEvents([tourneyEnrollLog(5000n, 5, packPlayers(10, 20))], c, 2); // bracket 5 = festival |
| 157 | + const ev = extractNotifEvents([tourneyResultLog(5000n)], c, 3); |
| 158 | + expect(ev).toEqual( |
| 159 | + expect.arrayContaining([ |
| 160 | + { type: "festival", recipient_wallet: OWNER_A, taruchi_id: "5000" }, |
| 161 | + { type: "festival", recipient_wallet: OWNER_B, taruchi_id: "5000" }, |
| 162 | + ]), |
| 163 | + ); |
| 164 | + expect(ev).toHaveLength(2); |
| 165 | + }); |
| 166 | + |
| 167 | + it("ignores a non-festival (duel-tier) tourney bracket", () => { |
| 168 | + const c = emptyCaches(); |
| 169 | + c.ownerByIndex.set(10, OWNER_A); |
| 170 | + extractNotifEvents([tourneyEnrollLog(6000n, 2, packPlayers(10))], c, 1); // bracket 2 = Veteran |
| 171 | + expect(extractNotifEvents([tourneyResultLog(6000n)], c, 2)).toEqual([]); |
| 172 | + }); |
| 173 | +}); |
0 commit comments