Skip to content

Commit dbf056b

Browse files
authored
Merge pull request #8 from Asphodel-OS/urco.taruchi-detail-endpoint
add taruchi detail endpoint
2 parents 1535c9f + 37b2198 commit dbf056b

4 files changed

Lines changed: 613 additions & 2 deletions

File tree

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { describe, expect, it } from "vitest";
2+
import { packU32 } from "./packUtils";
3+
import type { TaruchiLeaderboardRow } from "./types";
4+
import {
5+
buildTaruchiDetails,
6+
traitCode,
7+
unpackStats,
8+
unpackTraits,
9+
type BuildTaruchiDetailsInput,
10+
type TaruchiStatusRow,
11+
} from "./buildTaruchiDetails";
12+
13+
const i16 = (n: number): bigint => BigInt(n & 0xffff);
14+
const packStats = (health: number, power: number, harmony: number, violence: number): bigint =>
15+
i16(health) | (i16(power) << 16n) | (i16(harmony) << 32n) | (i16(violence) << 48n);
16+
const packTraits = (flower: number, body: number, eye: number, mouth: number, equipment: number): bigint =>
17+
BigInt(flower) | (BigInt(body) << 8n) | (BigInt(eye) << 16n) | (BigInt(mouth) << 24n) | (BigInt(equipment) << 32n);
18+
19+
describe("unpackStats", () => {
20+
it("unpacks four int16 lanes, sign-extended", () => {
21+
expect(unpackStats(packStats(50, 5, -3, 100))).toEqual({ health: 50, power: 5, harmony: -3, violence: 100 });
22+
});
23+
it("handles the int16 extremes", () => {
24+
expect(unpackStats(packStats(32767, -32768, 0, -1))).toEqual({
25+
health: 32767,
26+
power: -32768,
27+
harmony: 0,
28+
violence: -1,
29+
});
30+
});
31+
it("zero packs to all-zero stats", () => {
32+
expect(unpackStats(0n)).toEqual({ health: 0, power: 0, harmony: 0, violence: 0 });
33+
});
34+
});
35+
36+
describe("unpackTraits / traitCode", () => {
37+
it("unpacks the five slots from the uint40 layout", () => {
38+
expect(unpackTraits(packTraits(1, 2, 3, 4, 5))).toEqual({ flower: 1, body: 2, eye: 3, mouth: 4, equipment: 5 });
39+
});
40+
it("builds the BBEEMMEEFF code (body/eye/mouth/equipment/flower)", () => {
41+
expect(traitCode({ body: 2, eye: 3, mouth: 4, equipment: 5, flower: 1 })).toBe("0203040501");
42+
});
43+
});
44+
45+
function lbRow(
46+
over: Partial<TaruchiLeaderboardRow> & Pick<TaruchiLeaderboardRow, "taruchiId" | "taruchiIndex" | "ownerWallet">,
47+
): TaruchiLeaderboardRow {
48+
return {
49+
name: "",
50+
state: 1,
51+
imageUrl: "",
52+
wins: 0,
53+
losses: 0,
54+
tournaments: 0,
55+
bestPlacement: 8,
56+
winrate: 0,
57+
qualified: false,
58+
onyxWon: 0,
59+
onyxSpent: 0,
60+
...over,
61+
};
62+
}
63+
64+
function status(over: Partial<TaruchiStatusRow> & Pick<TaruchiStatusRow, "id">): TaruchiStatusRow {
65+
return {
66+
state: 1,
67+
level: 1,
68+
xp: 0,
69+
trainingPoints: 0,
70+
affinity: 0,
71+
budIndex: 0,
72+
traits: 0n,
73+
stats: 0n,
74+
...over,
75+
};
76+
}
77+
78+
describe("buildTaruchiDetails", () => {
79+
const cores = [
80+
{ id: 100n, owner: "0xAAA", index: 1 },
81+
{ id: 200n, owner: "0xBBB", index: 2 },
82+
{ id: 300n, owner: "0xCCC", index: 3 },
83+
];
84+
const statuses: TaruchiStatusRow[] = [
85+
status({
86+
id: 100n,
87+
state: 1,
88+
level: 5,
89+
xp: 10,
90+
trainingPoints: 2,
91+
affinity: 4,
92+
traits: packTraits(1, 2, 3, 4, 5),
93+
stats: packStats(50, 5, -3, 100),
94+
}),
95+
status({ id: 200n, state: 4, level: 11, budIndex: 7, affinity: 6 }),
96+
status({ id: 300n, state: 1, level: 1, affinity: 3 }),
97+
];
98+
const names = [{ id: 100n, name: "Alpha" }];
99+
// index 1 beats index 2 in a rookie duel (bracket 1). placements slot0=winner.
100+
const duels = [{ id: 999n, status: 2, bracket: 1, playerAIndex: 1, playerBIndex: 2 }];
101+
const results = [{ id: 999n, placements: packU32([1, 2]), time: 0 }];
102+
const byTaruchi = new Map<string, TaruchiLeaderboardRow>([
103+
[
104+
"100",
105+
lbRow({
106+
taruchiId: 100n,
107+
taruchiIndex: 1,
108+
ownerWallet: "0xaaa",
109+
wins: 1,
110+
losses: 0,
111+
tournaments: 1,
112+
bestPlacement: 1,
113+
winrate: 1,
114+
name: "Alpha",
115+
}),
116+
],
117+
[
118+
"200",
119+
lbRow({
120+
taruchiId: 200n,
121+
taruchiIndex: 2,
122+
ownerWallet: "0xbbb",
123+
wins: 0,
124+
losses: 1,
125+
tournaments: 1,
126+
bestPlacement: 2,
127+
}),
128+
],
129+
]);
130+
131+
const input: BuildTaruchiDetailsInput = {
132+
tourneys: [],
133+
duels,
134+
results,
135+
cores,
136+
statuses,
137+
names,
138+
byTaruchi,
139+
spriteFor: (core) => `sprite/${core.index}`,
140+
decodeName: (s) => s,
141+
};
142+
143+
const details = buildTaruchiDetails(input);
144+
145+
it("builds onchain status + unpacked stats/traits for a named taru", () => {
146+
const d = details.get("100")!;
147+
expect(d.name).toBe("Alpha");
148+
expect(d.level).toBe(5);
149+
expect(d.xp).toBe(10);
150+
expect(d.trainingPoints).toBe(2);
151+
expect(d.affinity).toBe(4);
152+
expect(d.imageUrl).toBe("sprite/1");
153+
expect(d.traitCode).toBe("0203040501");
154+
expect(d.traits).toEqual({ flower: 1, body: 2, eye: 3, mouth: 4, equipment: 5 });
155+
expect(d.stats).toEqual({ health: 50, power: 5, harmony: -3, violence: 100 });
156+
expect(d.ascended).toBe(false);
157+
});
158+
159+
it("reuses the leaderboard record and splits it per tier (tiers sum to the record)", () => {
160+
const d = details.get("100")!;
161+
expect(d.record).toMatchObject({ wins: 1, losses: 0, tournaments: 1, bestPlacement: 1, winrate: 1 });
162+
expect(d.bracketRecord.rookie).toEqual({ wins: 1, losses: 0 });
163+
expect(d.bracketRecord.veteran).toEqual({ wins: 0, losses: 0 });
164+
expect(d.bracketRecord.champion).toEqual({ wins: 0, losses: 0 });
165+
const tierW = d.bracketRecord.rookie.wins + d.bracketRecord.veteran.wins + d.bracketRecord.champion.wins;
166+
const tierL = d.bracketRecord.rookie.losses + d.bracketRecord.veteran.losses + d.bracketRecord.champion.losses;
167+
expect(tierW).toBe(d.record.wins);
168+
expect(tierL).toBe(d.record.losses);
169+
});
170+
171+
it("flags ascended state and carries budIndex", () => {
172+
const d = details.get("200")!;
173+
expect(d.ascended).toBe(true);
174+
expect(d.budIndex).toBe(7);
175+
expect(d.bracketRecord.rookie).toEqual({ wins: 0, losses: 1 });
176+
expect(d.name).toBe("Taruchi #2"); // unnamed → fallback
177+
});
178+
179+
it("zeroes the record for a never-played taru and uses the never-placed sentinel", () => {
180+
const d = details.get("300")!;
181+
expect(d.record).toEqual({
182+
wins: 0,
183+
losses: 0,
184+
tournaments: 0,
185+
bestPlacement: 8,
186+
winrate: 0,
187+
qualified: false,
188+
onyxWon: 0,
189+
onyxSpent: 0,
190+
});
191+
expect(d.bracketRecord).toEqual({
192+
rookie: { wins: 0, losses: 0 },
193+
veteran: { wins: 0, losses: 0 },
194+
champion: { wins: 0, losses: 0 },
195+
});
196+
expect(d.ascended).toBe(false);
197+
});
198+
199+
it("splits eight-player festival results into the collapsed tier bucket", () => {
200+
const festivalCores = Array.from({ length: 8 }, (_, i) => ({
201+
id: BigInt(1000 + i),
202+
owner: `0x${String(i + 1).padStart(40, "0")}`,
203+
index: i + 1,
204+
}));
205+
const festivalStatuses = festivalCores.map((core) => status({ id: core.id }));
206+
const festivalInput: BuildTaruchiDetailsInput = {
207+
tourneys: [{ id: 77n, status: 2, bracket: 4, players: packU32([1, 2, 3, 4, 5, 6, 7, 8]) }],
208+
duels: [],
209+
results: [{ id: 77n, placements: packU32([1, 2, 3, 4, 5, 6, 7, 8]) }],
210+
cores: festivalCores,
211+
statuses: festivalStatuses,
212+
names: [],
213+
byTaruchi: new Map([
214+
["1000", lbRow({ taruchiId: 1000n, taruchiIndex: 1, ownerWallet: festivalCores[0].owner, wins: 3, losses: 0 })],
215+
["1007", lbRow({ taruchiId: 1007n, taruchiIndex: 8, ownerWallet: festivalCores[7].owner, wins: 2, losses: 1 })],
216+
]),
217+
spriteFor: (core) => `sprite/${core.index}`,
218+
decodeName: (s) => s,
219+
};
220+
221+
const festivalDetails = buildTaruchiDetails(festivalInput);
222+
223+
expect(festivalDetails.get("1000")!.bracketRecord).toEqual({
224+
rookie: { wins: 3, losses: 0 },
225+
veteran: { wins: 0, losses: 0 },
226+
champion: { wins: 0, losses: 0 },
227+
});
228+
expect(festivalDetails.get("1007")!.bracketRecord).toEqual({
229+
rookie: { wins: 2, losses: 1 },
230+
veteran: { wins: 0, losses: 0 },
231+
champion: { wins: 0, losses: 0 },
232+
});
233+
});
234+
235+
it("does not accumulate bracket records for unrevealed tarus", () => {
236+
const unrevealedCore = { id: 400n, owner: "0xDDD", index: 4 };
237+
const out = buildTaruchiDetails({
238+
tourneys: [],
239+
duels: [{ id: 123n, status: 2, bracket: 1, playerAIndex: 4, playerBIndex: 99 }],
240+
results: [{ id: 123n, placements: packU32([4, 99]) }],
241+
cores: [unrevealedCore],
242+
statuses: [status({ id: 400n, state: 0 })],
243+
names: [],
244+
byTaruchi: new Map(),
245+
spriteFor: (core) => `sprite/${core.index}`,
246+
decodeName: (s) => s,
247+
});
248+
249+
expect(out.get("400")!.record).toEqual({
250+
wins: 0,
251+
losses: 0,
252+
tournaments: 0,
253+
bestPlacement: 8,
254+
winrate: 0,
255+
qualified: false,
256+
onyxWon: 0,
257+
onyxSpent: 0,
258+
});
259+
expect(out.get("400")!.bracketRecord).toEqual({
260+
rookie: { wins: 0, losses: 0 },
261+
veteran: { wins: 0, losses: 0 },
262+
champion: { wins: 0, losses: 0 },
263+
});
264+
});
265+
});

0 commit comments

Comments
 (0)