Skip to content

Commit b1513c1

Browse files
committed
Fixed pagination and mutex for txs
1 parent 8753e72 commit b1513c1

5 files changed

Lines changed: 173 additions & 35 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,17 @@ The GraphQL indexer at `GRAPHQL_URL` provides the following main entity types:
160160

161161
**Query Patterns:**
162162
- Use `where` filters for targeted queries (e.g., `where: { operator: $address }`)
163-
- Pagination via `limit`, `orderBy`, `orderDirection`
163+
- Cursor-based pagination via `limit`, `after`, `before` (NOT offset-based `skip`)
164+
- Standard query arguments: `where`, `orderBy`, `orderDirection`, `before`, `after`, `limit`
164165
- Nested relationship queries (e.g., `battle { players { character } }`)
165166
- String matching with `_starts_with`, `_not`, `_gt`, etc.
167+
- All list queries return results wrapped in `{ items: [...] }` structure
168+
169+
**Pagination:**
170+
- The indexer uses cursor-based pagination, not offset-based
171+
- Use `after` parameter with the last item's ID from previous page
172+
- Use `limit` to control page size (default varies by entity type)
173+
- The `queryAllPages` utility function handles pagination automatically
166174

167175
### Common Issues
168176

src/node/BattleOperator.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,12 @@ export class BattleOperator {
132132
// Use GraphQL to get battle state and recent turn data
133133
const graphqlClient = createGraphQLClient({ GRAPHQL_URL: this.config.graphqlUrl });
134134

135-
// Get battle data from GraphQL
136-
const battleResult = await graphqlClient.query<{ battles: { items: Battle[] } }>(GraphQLQueries.getBattlesByGameState);
135+
// Get specific battle data from GraphQL
136+
const battleResult = await graphqlClient.query<{ battle: Battle | null }>(GraphQLQueries.getBattleById, {
137+
battleId: this.config.gameAddress.toLowerCase()
138+
});
137139

138-
const battle = battleResult.battles.items.find(b => b.id.toLowerCase() === this.config.gameAddress.toLowerCase());
140+
const battle = battleResult.battle;
139141

140142
if (!battle) {
141143
this.log("Battle not found in GraphQL, checking contract state");

src/node/CharacterOperator.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export class CharacterOperator {
3333
private lastActionTime: number = 0;
3434
private eventUnsubscribe?: () => void;
3535
private logger: Logger;
36+
private isExecuting: boolean = false;
3637

3738
constructor(config: CharacterOperatorConfig) {
3839
this.config = config;
@@ -140,18 +141,27 @@ export class CharacterOperator {
140141
}
141142

142143
private async executeBotLogic(): Promise<boolean> {
143-
this.log('executeBotLogic', {
144-
gameAddress: this.config.gameAddress,
145-
playerId: this.config.playerId,
146-
teamA: this.config.teamA
147-
});
144+
// Prevent concurrent execution
145+
if (this.isExecuting) {
146+
this.log('Already executing bot logic, skipping...');
147+
return true;
148+
}
149+
150+
this.isExecuting = true;
151+
152+
try {
153+
this.log('executeBotLogic', {
154+
gameAddress: this.config.gameAddress,
155+
playerId: this.config.playerId,
156+
teamA: this.config.teamA
157+
});
148158

149-
const publicClient = createPublicClient({
150-
chain: arbitrum,
151-
transport: createAuthenticatedHttpTransport(this.config.ethRpcUrl, { ETH_RPC_URL: this.config.ethRpcUrl })
152-
});
159+
const publicClient = createPublicClient({
160+
chain: arbitrum,
161+
transport: createAuthenticatedHttpTransport(this.config.ethRpcUrl, { ETH_RPC_URL: this.config.ethRpcUrl })
162+
});
153163

154-
try {
164+
try {
155165
// Check if it's our team's turn using GraphQL for efficiency
156166
const graphqlClient = createGraphQLClient({ GRAPHQL_URL: this.config.graphqlUrl });
157167
const battleResult = await graphqlClient.query<{ battles: { items: any[] } }>(GraphQLQueries.getBattlesByGameState);
@@ -194,6 +204,9 @@ export class CharacterOperator {
194204
this.error("Error in executeBotLogic:", error);
195205
return true;
196206
}
207+
} finally {
208+
this.isExecuting = false;
209+
}
197210
}
198211

199212
private async playTurn(publicClient: any) {
@@ -332,6 +345,18 @@ export class CharacterOperator {
332345
attemptedCount: attemptedCardIndices.size
333346
});
334347

348+
// Check if it's still our turn before ending
349+
const isStillOurTurn = await publicClient.readContract({
350+
address: gameAddress,
351+
abi: BattleABI as Abi,
352+
functionName: 'isTeamATurn'
353+
}) as boolean;
354+
355+
if (isStillOurTurn !== this.config.teamA) {
356+
this.log('Turn has already changed, not ending turn');
357+
break;
358+
}
359+
335360
// End the turn
336361
const endTurnData = encodeFunctionData({
337362
abi: BattleABI as Abi,
@@ -381,9 +406,8 @@ export class CharacterOperator {
381406

382407
this.log(`Randomly selected card ID ${playableCardId} at hand index ${playableHandIndex}, energy cost: ${energyCost}`);
383408

384-
// Get enemy players to target
385-
const battlePlayers = await this.getBattlePlayers();
386-
const enemyPlayers = battlePlayers.filter(p => p.teamA !== this.config.teamA && !p.eliminated);
409+
// Get active enemy players to target
410+
const enemyPlayers = await this.getActiveEnemyPlayers();
387411

388412
if (enemyPlayers.length === 0) {
389413
this.log('No enemy players available');
@@ -487,4 +511,13 @@ export class CharacterOperator {
487511
});
488512
return result.battlePlayers.items;
489513
}
514+
515+
private async getActiveEnemyPlayers(): Promise<BattlePlayer[]> {
516+
const graphqlClient = createGraphQLClient({ GRAPHQL_URL: this.config.graphqlUrl });
517+
const result = await graphqlClient.query<{ battlePlayers: { items: BattlePlayer[] } }>(GraphQLQueries.getActiveEnemyPlayers, {
518+
battleId: this.config.gameAddress.toLowerCase(),
519+
isTeamA: this.config.teamA
520+
});
521+
return result.battlePlayers.items;
522+
}
490523
}

src/node/OperatorManager.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createPublicClient, type Abi } from "viem";
22
import { arbitrum } from "viem/chains";
33
import BattleABI from "../contracts/abis/Battle.json";
4-
import { createGraphQLClient, GraphQLQueries, type Battle, type Character, type Ziggurat } from "../utils/graphql";
4+
import { createGraphQLClient, GraphQLQueries, queryAllPages, type Battle, type Character, type Ziggurat } from "../utils/graphql";
55
import { createAuthenticatedHttpTransport } from "../utils/rpc";
66
import { CharacterOperator } from "./CharacterOperator";
77
import { BattleOperator } from "./BattleOperator";
@@ -42,17 +42,19 @@ export class OperatorManager {
4242
const graphqlClient = createGraphQLClient({ GRAPHQL_URL: this.config.graphqlUrl });
4343

4444
// Step 1: Get all characters owned by the operator
45-
const charactersResult = await graphqlClient.query<{ characters: { items: Character[] } }>(
45+
// Get all characters owned by the operator (with pagination)
46+
const characters = await queryAllPages<{ items: Character[] }>(
47+
graphqlClient,
4648
GraphQLQueries.getCharactersByOwner,
4749
{ owner: this.config.operatorAddress.toLowerCase() }
4850
);
4951

50-
if (!charactersResult.characters.items.length) {
52+
if (!characters.length) {
5153
this.logger.debug("No characters found for operator");
5254
return;
5355
}
5456

55-
const characterIds = charactersResult.characters.items.map(c => c.id);
57+
const characterIds = characters.map(c => c.id);
5658
this.logger.debug(`Found ${characterIds.length} characters for operator`);
5759

5860
// Step 2: Get all active battles where these characters are playing
@@ -113,18 +115,21 @@ export class OperatorManager {
113115
try {
114116
const graphqlClient = createGraphQLClient({ GRAPHQL_URL: this.config.graphqlUrl });
115117

116-
const result = await graphqlClient.query<{ battles: { items: Battle[] } }>(GraphQLQueries.getBattlesWithOperator, {
117-
operator: this.config.operatorAddress.toLowerCase()
118-
});
118+
// Get all battles where we are operator (with pagination)
119+
const battles = await queryAllPages<{ items: Battle[] }>(
120+
graphqlClient,
121+
GraphQLQueries.getBattlesWithOperator,
122+
{ operator: this.config.operatorAddress.toLowerCase() }
123+
);
119124

120-
this.logger.info(`Found ${result.battles.items.length} battles where we are operator`);
125+
this.logger.info(`Found ${battles.length} battles where we are operator`);
121126

122127
const publicClient = createPublicClient({
123128
chain: arbitrum,
124129
transport: createAuthenticatedHttpTransport(this.config.ethRpcUrl, { ETH_RPC_URL: this.config.ethRpcUrl })
125130
});
126131

127-
const battleAddresses = result.battles.items.map(battle => battle.id);
132+
const battleAddresses = battles.map(battle => battle.id);
128133

129134
const gameStateContracts = battleAddresses.map(address => ({
130135
address: address as `0x${string}`,
@@ -136,7 +141,7 @@ export class OperatorManager {
136141
contracts: gameStateContracts
137142
});
138143

139-
const activeBattles = result.battles.items.filter((battle, index) => {
144+
const activeBattles = battles.filter((battle, index) => {
140145
const response = gameStateResponses[index];
141146
if (response.status === 'failure') {
142147
this.logger.error(`Failed to get game state for battle ${battle.id}:`, response.error);

src/utils/graphql.ts

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,62 @@ export function createGraphQLClient(config: GraphQLConfig): GraphQLClient {
7070
};
7171
}
7272

73+
// Pagination utility for queries that return lists
74+
// Uses cursor-based pagination with 'after' parameter
75+
export async function queryAllPages<T extends { items: any[] }>(
76+
client: GraphQLClient,
77+
query: string,
78+
variables: Record<string, any> = {},
79+
pageSize: number = 100
80+
): Promise<T['items']> {
81+
const allItems: T['items'] = [];
82+
let afterCursor: string | undefined = undefined;
83+
let hasMore = true;
84+
let totalFetched = 0;
85+
86+
while (hasMore) {
87+
// Add pagination variables
88+
const paginatedVariables: Record<string, any> = {
89+
...variables,
90+
limit: pageSize,
91+
...(afterCursor ? { after: afterCursor } : {})
92+
};
93+
94+
const result: { [key: string]: T } = await client.query<{ [key: string]: T }>(query, paginatedVariables);
95+
96+
// Find the first result that has an items array
97+
const resultKey = Object.keys(result).find(key =>
98+
result[key] && Array.isArray((result[key] as any).items)
99+
);
100+
101+
if (!resultKey) {
102+
logger.error({ result }, 'No items array found in query result');
103+
break;
104+
}
105+
106+
const items: T['items'] = (result[resultKey] as T).items;
107+
allItems.push(...items);
108+
totalFetched += items.length;
109+
110+
// Check if we got less than a full page
111+
hasMore = items.length === pageSize;
112+
113+
// Get the last item's ID as the cursor for the next page
114+
if (hasMore && items.length > 0) {
115+
const lastItem: any = items[items.length - 1];
116+
afterCursor = lastItem.id || lastItem.address || JSON.stringify(lastItem);
117+
}
118+
119+
// Safety check to prevent infinite loops
120+
if (totalFetched > 10000) {
121+
logger.warn('Pagination safety limit reached (10000 items)');
122+
break;
123+
}
124+
}
125+
126+
return allItems;
127+
}
128+
73129
// GraphQL types based on schema introspection
74130
export enum PartyState {
75131
CREATED,
@@ -144,11 +200,13 @@ export interface Ziggurat {
144200
}
145201

146202
// Query helpers
203+
// NOTE: All queries that return lists use cursor-based pagination with $limit and $after parameters
204+
// Use the queryAllPages utility function when you need to fetch all results
147205
export const GraphQLQueries = {
148206
// Ziggurat queries
149207
getPartiesByZigguratWithStateDoorChosen: `
150-
query GetPartiesByZiggurat($zigguratAddress: String!) {
151-
partys(where: { zigguratAddress: $zigguratAddress, state: "1" }) {
208+
query GetPartiesByZiggurat($zigguratAddress: String!, $limit: Int, $after: String) {
209+
partys(where: { zigguratAddress: $zigguratAddress, state: "1" }, limit: $limit, after: $after) {
152210
items {
153211
id
154212
zigguratAddress
@@ -264,13 +322,27 @@ export const GraphQLQueries = {
264322
}
265323
`,
266324

325+
getBattleById: `
326+
query GetBattleById($battleId: String!) {
327+
battle(id: $battleId) {
328+
id
329+
gameStartedAt
330+
currentTurn
331+
teamAStarts
332+
turnDuration
333+
winner
334+
}
335+
}
336+
`,
337+
267338
getBattlePlayers: `
268-
query GetBattlePlayers($battleId: String!) {
269-
battlePlayers(where: { id_starts_with: $battleId }) {
339+
query GetBattlePlayers($battleId: String!, $limit: Int = 100, $after: String) {
340+
battlePlayers(where: { id_starts_with: $battleId }, limit: $limit, after: $after) {
270341
items {
271342
id
272343
playerId
273344
teamA
345+
eliminated
274346
character {
275347
id
276348
name
@@ -282,6 +354,23 @@ export const GraphQLQueries = {
282354
}
283355
`,
284356

357+
getActiveEnemyPlayers: `
358+
query GetActiveEnemyPlayers($battleId: String!, $isTeamA: Boolean!) {
359+
battlePlayers(where: { id_starts_with: $battleId, teamA_not: $isTeamA, eliminated: false }) {
360+
items {
361+
id
362+
playerId
363+
teamA
364+
eliminated
365+
character {
366+
id
367+
name
368+
}
369+
}
370+
}
371+
}
372+
`,
373+
285374
getBattleTurns: `
286375
query GetBattleTurns($battleId: String!) {
287376
battleTurns(where: { id_starts_with: $battleId }, orderBy: "turn", orderDirection: "desc", limit: 1) {
@@ -298,15 +387,16 @@ export const GraphQLQueries = {
298387

299388
// OperatorManager queries
300389
getBattlesWithOperator: `
301-
query GetBattlesWithOperator($operator: String!) {
302-
battles(where: { operator: $operator }, limit: 1000) {
390+
query GetBattlesWithOperator($operator: String!, $limit: Int = 100, $after: String) {
391+
battles(where: { operator: $operator, winner: null }, limit: $limit, after: $after) {
303392
items {
304393
id
305394
operator
306395
gameStartedAt
307396
currentTurn
308397
teamAStarts
309398
turnDuration
399+
winner
310400
}
311401
}
312402
}
@@ -340,8 +430,8 @@ export const GraphQLQueries = {
340430
`,
341431

342432
getCharactersByOwner: `
343-
query GetCharactersByOwner($owner: String!) {
344-
characters(where: { owner: $owner }) {
433+
query GetCharactersByOwner($owner: String!, $limit: Int = 100, $after: String) {
434+
characters(where: { owner: $owner }, limit: $limit, after: $after) {
345435
items {
346436
id
347437
name

0 commit comments

Comments
 (0)