Skip to content

Commit f4a2cf0

Browse files
Increase stdout maxBuffer to prevent working state failures
Raise the stdout buffer limit when fetching working state so large outputs no longer fail with stdout maxBuffer length exceeded.
1 parent 75d0fcd commit f4a2cf0

256 files changed

Lines changed: 27476 additions & 40 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
"Bash(findstr /I /C:\"AndroidRuntime\" /C:\"FATAL\" /C:\"battleship\")",
66
"Bash(rm \"C:\\\\Users\\\\daasa\\\\AppData\\\\Local\\\\Temp\\\\logcat.txt\")",
77
"Bash(del \"CUsersdaasaAppDataLocalTemplogcat.txt\")",
8-
"Bash(./gradlew :app:assembleDebug -q)"
8+
"Bash(./gradlew :app:assembleDebug -q)",
9+
"Bash(./gradlew :feature:game:compileDebugKotlin :core:multiplayer:compileDebugKotlin :core:testing:compileDebugKotlin)",
10+
"Bash(./gradlew :feature:game:test :core:multiplayer:test)",
11+
"Bash(./gradlew test *)"
912
]
1013
}
1114
}

.graphify_python

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
C:\Users\daasa\AppData\Local\Programs\Python\Python311\python.exe

core/domain/src/main/kotlin/com/battleship/fleetcommand/core/domain/multiplayer/FirebaseMatchRepository.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ interface FirebaseMatchRepository {
4242
): Result<ShotResolutionResult>
4343

4444
suspend fun flipTurn(gameId: String, nextPlayerUid: String): Result<Unit>
45+
46+
/**
47+
* Client-side fallback used when the resolveShot Cloud Function is unavailable.
48+
* Atomically writes the shot result + shipId and flips currentTurn in a single
49+
* multi-path update so attackers and defenders see a consistent state.
50+
*/
51+
suspend fun writeShotResolution(
52+
gameId: String,
53+
shooterUid: String,
54+
pushKey: String,
55+
result: FireResult,
56+
shipId: String?,
57+
nextTurnUid: String,
58+
): Result<Unit>
4559
}
4660

4761
/**

core/multiplayer/src/main/kotlin/com/battleship/fleetcommand/core/multiplayer/mapper/GameSyncMapper.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ class GameSyncMapper @Inject constructor() {
108108
*/
109109
fun mapShotSnapshot(snapshot: DataSnapshot): ShotData? {
110110
return try {
111+
val pushKey = snapshot.key ?: ""
111112
val row = snapshot.child(FirebaseSchema.SHOT_ROW).getValue(Int::class.java)
112113
?: return null
113114
val col = snapshot.child(FirebaseSchema.SHOT_COL).getValue(Int::class.java)
@@ -125,6 +126,7 @@ class GameSyncMapper @Inject constructor() {
125126
result = resultStr?.toFireResult(),
126127
shipId = shipIdStr,
127128
timestamp = timestamp,
129+
pushKey = pushKey,
128130
)
129131
} catch (e: Exception) {
130132
Timber.w(e, "GameSyncMapper: failed to map shot at ${snapshot.ref.path}")

core/multiplayer/src/main/kotlin/com/battleship/fleetcommand/core/multiplayer/repository/FirebaseMatchRepositoryImpl.kt

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.battleship.fleetcommand.core.multiplayer.FirebaseSchema
1717
import com.battleship.fleetcommand.core.multiplayer.auth.FirebaseAuthManager
1818
import com.battleship.fleetcommand.core.multiplayer.mapper.GameSyncMapper
1919
import com.battleship.fleetcommand.core.multiplayer.mapper.ShipPlacementDto
20+
import com.battleship.fleetcommand.core.multiplayer.mapper.toSchemaString
2021
import com.battleship.fleetcommand.core.multiplayer.matchmaking.MatchmakingRepository
2122
import com.google.firebase.database.ChildEventListener
2223
import com.google.firebase.database.DataSnapshot
@@ -195,7 +196,7 @@ class FirebaseMatchRepositoryImpl @Inject constructor(
195196
} catch (_: Exception) {}
196197
}
197198

198-
// ── Server-side operations via Cloud Functions ───────────────────────────
199+
// ── Server-side operations via Cloud Functions (with client-side fallback) ───
199200

200201
override suspend fun claimVictory(gameId: String): Result<Unit> {
201202
return try {
@@ -204,7 +205,22 @@ class FirebaseMatchRepositoryImpl @Inject constructor(
204205
.await()
205206
Result.success(Unit)
206207
} catch (e: Exception) {
207-
Timber.w(e, "claimVictory Cloud Function failed for game=$gameId")
208+
Timber.w(e, "claimVictory Cloud Function unavailable; falling back to direct write")
209+
claimVictoryClientSide(gameId)
210+
}
211+
}
212+
213+
private suspend fun claimVictoryClientSide(gameId: String): Result<Unit> {
214+
val myUid = authManager.currentUid ?: return Result.failure(Exception("Not authenticated"))
215+
return try {
216+
val updates = mapOf<String, Any?>(
217+
"${FirebaseSchema.GAMES}/$gameId/${FirebaseSchema.META}/${FirebaseSchema.WINNER}" to myUid,
218+
"${FirebaseSchema.GAMES}/$gameId/${FirebaseSchema.META}/${FirebaseSchema.STATUS}" to FirebaseSchema.STATUS_FINISHED,
219+
)
220+
database.reference.updateChildren(updates).await()
221+
Result.success(Unit)
222+
} catch (e: Exception) {
223+
Timber.e(e, "claimVictory client-side fallback failed for game=$gameId")
208224
Result.failure(e)
209225
}
210226
}
@@ -216,7 +232,32 @@ class FirebaseMatchRepositoryImpl @Inject constructor(
216232
.await()
217233
Result.success(Unit)
218234
} catch (e: Exception) {
219-
Timber.w(e, "forfeit Cloud Function failed for game=$gameId")
235+
Timber.w(e, "forfeit Cloud Function unavailable; falling back to direct write")
236+
forfeitClientSide(gameId)
237+
}
238+
}
239+
240+
private suspend fun forfeitClientSide(gameId: String): Result<Unit> {
241+
val myUid = authManager.currentUid ?: return Result.failure(Exception("Not authenticated"))
242+
return try {
243+
val metaRef = database.getReference("${FirebaseSchema.GAMES}/$gameId/${FirebaseSchema.META}")
244+
val metaSnap = metaRef.get().await()
245+
val hostUid = metaSnap.child(FirebaseSchema.HOST_UID).getValue(String::class.java)
246+
val guestUid = metaSnap.child(FirebaseSchema.GUEST_UID).getValue(String::class.java)
247+
val opponentUid = when (myUid) {
248+
hostUid -> guestUid
249+
guestUid -> hostUid
250+
else -> null
251+
} ?: return Result.failure(Exception("No opponent found for forfeit"))
252+
253+
val updates = mapOf<String, Any?>(
254+
"${FirebaseSchema.GAMES}/$gameId/${FirebaseSchema.META}/${FirebaseSchema.WINNER}" to opponentUid,
255+
"${FirebaseSchema.GAMES}/$gameId/${FirebaseSchema.META}/${FirebaseSchema.STATUS}" to FirebaseSchema.STATUS_FINISHED,
256+
)
257+
database.reference.updateChildren(updates).await()
258+
Result.success(Unit)
259+
} catch (e: Exception) {
260+
Timber.e(e, "forfeit client-side fallback failed for game=$gameId")
220261
Result.failure(e)
221262
}
222263
}
@@ -267,4 +308,32 @@ class FirebaseMatchRepositoryImpl @Inject constructor(
267308
Result.failure(e)
268309
}
269310
}
311+
312+
override suspend fun writeShotResolution(
313+
gameId: String,
314+
shooterUid: String,
315+
pushKey: String,
316+
result: FireResult,
317+
shipId: String?,
318+
nextTurnUid: String,
319+
): Result<Unit> {
320+
if (pushKey.isBlank()) {
321+
return Result.failure(IllegalArgumentException("writeShotResolution requires a non-blank pushKey"))
322+
}
323+
return try {
324+
val basePath = "${FirebaseSchema.GAMES}/$gameId/${FirebaseSchema.SHOTS}/$shooterUid/$pushKey"
325+
val updates = mutableMapOf<String, Any?>(
326+
"$basePath/${FirebaseSchema.SHOT_RESULT}" to result.toSchemaString(),
327+
"${FirebaseSchema.GAMES}/$gameId/${FirebaseSchema.META}/${FirebaseSchema.CURRENT_TURN}" to nextTurnUid,
328+
)
329+
if (shipId != null) {
330+
updates["$basePath/${FirebaseSchema.SHOT_SHIP_ID}"] = shipId
331+
}
332+
database.reference.updateChildren(updates).await()
333+
Result.success(Unit)
334+
} catch (e: Exception) {
335+
Timber.e(e, "writeShotResolution failed for game=$gameId shooter=$shooterUid pushKey=$pushKey")
336+
Result.failure(e)
337+
}
338+
}
270339
}

core/testing/src/main/kotlin/com/battleship/fleetcommand/core/testing/FakeFirebaseDatabase.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,28 @@ class FakeFirebaseDatabase : FirebaseMatchRepository {
232232
return Result.success(Unit)
233233
}
234234

235+
override suspend fun writeShotResolution(
236+
gameId: String,
237+
shooterUid: String,
238+
pushKey: String,
239+
result: FireResult,
240+
shipId: String?,
241+
nextTurnUid: String,
242+
): Result<Unit> {
243+
val node = games[gameId]
244+
?: return Result.failure(Exception("writeShotResolution: game not found: $gameId"))
245+
val shots = node.shots[shooterUid]
246+
?: return Result.failure(Exception("writeShotResolution: no shots for $shooterUid"))
247+
val shotIndex = pushKey.toIntOrNull() ?: shots.indexOfFirst { it.result == null }
248+
val shot = shots.getOrNull(shotIndex)
249+
?: return Result.failure(Exception("writeShotResolution: shot not found at $pushKey"))
250+
shot.result = result
251+
shot.shipId = shipId
252+
node.currentTurn = nextTurnUid
253+
stateFlows[gameId]?.value = node
254+
return Result.success(Unit)
255+
}
256+
235257
// ── Test helpers ──────────────────────────────────────────────────────────
236258

237259
/** Directly writes a shot result — useful for attacker-side tests. */
@@ -317,7 +339,8 @@ class FakeFirebaseDatabase : FirebaseMatchRepository {
317339
col = col,
318340
result = result,
319341
shipId = shipId,
320-
timestamp = timestamp
342+
timestamp = timestamp,
343+
pushKey = index.toString(),
321344
)
322345

323346
private fun generateRoomCode(): String {

database.rules.json

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,51 @@
88
},
99
"games": {
1010
"$gameId": {
11-
".read": "auth != null && (\n data.child('meta/hostUid').val() === auth.uid ||\n data.child('meta/guestUid').val() === auth.uid\n )",
11+
".read": "auth != null && (
12+
data.child('meta/hostUid').val() === auth.uid ||
13+
data.child('meta/guestUid').val() === auth.uid
14+
)",
1215
"meta": {
1316
"hostUid": {
1417
".write": "auth != null && !data.exists()"
1518
},
1619
"guestUid": {
17-
".write": "auth != null && (\n data.val() === null && newData.val() === auth.uid\n )"
20+
".write": "auth != null && (
21+
data.val() === null && newData.val() === auth.uid
22+
)"
1823
},
1924
"status": {
20-
".write": "auth != null && (\n (data.val() === null && newData.val() === 'waiting') ||\n (data.val() === 'waiting' && newData.val() === 'setup' &&\n root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid) ||\n (data.val() === 'setup' && newData.val() === 'battle')\n )"
25+
".write": "auth != null && (
26+
(data.val() === null && newData.val() === 'waiting') ||
27+
(data.val() === 'waiting' && newData.val() === 'setup' &&
28+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid) ||
29+
(data.val() === 'setup' && newData.val() === 'battle') ||
30+
(data.val() === 'battle' && newData.val() === 'finished' && (
31+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
32+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
33+
))
34+
)"
2135
},
2236
"winner": {
23-
".write": false
37+
".write": "auth != null && data.val() === null && (
38+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
39+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
40+
)"
2441
},
2542
"currentTurn": {
26-
".write": "auth != null && !data.exists()"
43+
".write": "auth != null && (
44+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
45+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
46+
)"
2747
},
2848
"createdAt": {
2949
".write": "auth != null && !data.exists()"
3050
},
3151
"updatedAt": {
32-
".write": "auth != null && (\n root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||\n root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid\n )"
52+
".write": "auth != null && (
53+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
54+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
55+
)"
3356
},
3457
"roomCode": {
3558
".write": "auth != null && !data.exists()"
@@ -50,22 +73,35 @@
5073
"$shooterUid": {
5174
".write": "auth != null && auth.uid === $shooterUid",
5275
"$shotPushKey": {
53-
".write": "auth != null && auth.uid === $shooterUid && (\n !data.exists() ||\n (newData.child('timestamp').val() - data.child('timestamp').val() > 500)\n )",
76+
".write": "auth != null && (
77+
auth.uid === $shooterUid ||
78+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
79+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
80+
)",
5481
"result": {
55-
".write": false
82+
".write": "auth != null && (
83+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
84+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
85+
)"
5686
},
5787
"shipId": {
58-
".write": false
88+
".write": "auth != null && (
89+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
90+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
91+
)"
5992
}
6093
}
6194
}
6295
},
6396
"chat": {
6497
"$messageId": {
65-
".write": "auth != null && (\n root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||\n root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid\n ) && newData.child('text').val().length <= 50"
98+
".write": "auth != null && (
99+
root.child('games').child($gameId).child('meta/hostUid').val() === auth.uid ||
100+
root.child('games').child($gameId).child('meta/guestUid').val() === auth.uid
101+
) && newData.child('text').val().length <= 120"
66102
}
67103
}
68104
}
69105
}
70106
}
71-
}
107+
}

0 commit comments

Comments
 (0)