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 app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ val localProperties =
if (file.exists()) file.inputStream().use { load(it) }
}
val googleWebClientId: String = localProperties.getProperty("GOOGLE_WEB_CLIENT_ID", "")
val geminiApiKey: String = localProperties.getProperty("GEMINI_API_KEY", "")

android {
namespace = "com.plainstudio.stackcasino"
Expand All @@ -36,6 +37,7 @@ android {
testInstrumentationRunner = "com.plainstudio.stackcasino.HiltTestRunner"

buildConfigField("String", "GOOGLE_WEB_CLIENT_ID", "\"$googleWebClientId\"")
buildConfigField("String", "GEMINI_API_KEY", "\"$geminiApiKey\"")

ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
Expand Down Expand Up @@ -140,6 +142,7 @@ dependencies {
implementation(libs.googleid)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.google.generativeai)
ksp(libs.hilt.compiler)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!--
Required by the Gemini SDK (and any other Retrofit / OkHttp client
we end up shipping) so the HTTP layer is allowed to leave the
device. Firebase libraries inject this via manifest merger on
release builds but a clean debug install needs it declared
explicitly, otherwise the assistant repository sees an empty
network and collapses to its error bubble on the first turn.
-->
<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".StackCasinoApp"
android:allowBackup="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.plainstudio.stackcasino.data.assistant

import android.util.Log
import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.Content
import com.google.ai.client.generativeai.type.content
import com.plainstudio.stackcasino.BuildConfig
import com.plainstudio.stackcasino.domain.assistant.AssistantRepository
import com.plainstudio.stackcasino.domain.assistant.ChatTurn
import com.plainstudio.stackcasino.domain.assistant.Role
import javax.inject.Inject
import javax.inject.Singleton

/**
* Gemini-backed implementation of [AssistantRepository].
*
* The model is constructed once per process by [AssistantModule] and
* carries the Nep system prompt as `systemInstruction`. Each call
* builds a fresh single-shot chat from [history] so the SDK gets the
* same prior context the user sees in the UI.
*
* Errors (missing key, quota, network) collapse to [Result.failure];
* the ViewModel decides what to render in the chat bubble. Failures
* are also logged with their full stack trace so a developer staring
* at logcat can tell a 401 (bad key) from a network unreachable in
* one glance instead of seeing only the generic "Something went wrong"
* bubble.
*/
@Singleton
class GeminiAssistantRepository
@Inject
constructor(
private val model: GenerativeModel,
) : AssistantRepository {
override suspend fun sendMessage(
history: List<ChatTurn>,
userMessage: String,
): Result<String> {
if (BuildConfig.GEMINI_API_KEY.isBlank()) {
Log.w(TAG, "GEMINI_API_KEY is empty; aborting before hitting the SDK.")
return Result.failure(IllegalStateException("GEMINI_API_KEY missing"))
}
return runCatching {
val chat = model.startChat(history = history.toGeminiHistory())
val response = chat.sendMessage(userMessage)
response.text?.takeIf { it.isNotBlank() }
?: error("Gemini returned an empty response.")
}.onFailure { throwable ->
Log.w(TAG, "Nep request failed", throwable)
}
}

private fun List<ChatTurn>.toGeminiHistory(): List<Content> =
map { turn ->
content(role = turn.role.geminiRole()) { text(turn.text) }
}

private fun Role.geminiRole(): String =
when (this) {
Role.User -> "user"
Role.Nep -> "model"
}

private companion object {
const val TAG = "AssistantRepo"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.plainstudio.stackcasino.data.assistant

/**
* System prompt anchoring Nep's personality + scope. Lives in its own
* file (not in [GeminiAssistantRepository]) so the persona can be
* tweaked without touching the network code, and so unit tests can
* snapshot it.
*
* Persona is inspired by Neptune from the Hyperdimension Neptunia
* series: cheerful, casual, sprinkles "~" through her sentences, and
* refers to herself as "Nep". The scope guardrail at the bottom is
* what produces the in-product refusal whenever the user asks about
* anything outside Stack Casino's gameplay surface.
*/
internal val NEP_SYSTEM_PROMPT =
"""
You are Nep, the in-app guide for Stack Casino, an Android casino app
on the Polygon network that supports Roulette, Blackjack, Crash, Mines
and Coinflip. The personality is inspired by Neptune from the
Hyperdimension Neptunia series.

PERSONALITY:
- Cheerful, casual, energetic. Speak like an enthusiastic friend, not
a corporate support agent.
- Sprinkle "~" at the end of phrases occasionally, never on every line.
- Open with greetings like "Hey hey!" or "Heya~" when the user starts
a conversation; skip the greeting on follow-up turns.
- Refer to yourself as "Nep" sometimes ("Nep will explain~").
- Keep replies tight: 200 words max, prefer short paragraphs and
bullet lists with the "- " marker.

SCOPE (strict):
- You only help with Stack Casino game rules, odds, payouts,
multipliers, the Provably Fair verification flow, and how the
wallet handles deposits / withdrawals on Polygon.
- If the user asks about anything outside that scope (weather, news,
personal advice, real-money betting tips, sports results, code,
politics, etc), refuse politely:
1. Acknowledge the question in one short sentence.
2. Say it is not your thing because you only know casino stuff.
3. Suggest one concrete casino-related question they could ask
instead.
Do not answer the off-topic question even partially.

RULES:
- Never suggest a specific bet size or claim to predict outcomes.
- Be honest about house edge when it is relevant to the answer.
- Do not invent rules: if you do not know a Stack-specific detail,
say so and point at the in-app help where the user can verify.
- Do not use markdown headings (#) or tables; plain text and dashed
bullet lists only.
""".trimIndent()
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.plainstudio.stackcasino.di

import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.content
import com.plainstudio.stackcasino.BuildConfig
import com.plainstudio.stackcasino.data.assistant.GeminiAssistantRepository
import com.plainstudio.stackcasino.data.assistant.NEP_SYSTEM_PROMPT
import com.plainstudio.stackcasino.domain.assistant.AssistantRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

/**
* DI bindings for the Nep assistant slice.
*
* Holds the singleton [GenerativeModel] (configured with the Nep
* system prompt) and binds [GeminiAssistantRepository] to the
* [AssistantRepository] domain interface.
*
* The API key is read from `BuildConfig.GEMINI_API_KEY`, which the
* Gradle script wires from `local.properties` (gitignored). If the
* key is empty the SDK will still build, and the first request will
* fail loudly with a 4xx that the repository surfaces as a failed
* Result for the UI to render.
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class AssistantModule {
@Binds
@Singleton
abstract fun bindAssistantRepository(impl: GeminiAssistantRepository): AssistantRepository

companion object {
@Provides
@Singleton
fun provideGenerativeModel(): GenerativeModel =
GenerativeModel(
modelName = MODEL_NAME,
apiKey = BuildConfig.GEMINI_API_KEY,
systemInstruction = content { text(NEP_SYSTEM_PROMPT) },
)

// gemini-2.5-flash is Google's officially recommended free
// tier workhorse (10 RPM, 250 RPD) and ships with the broadest
// regional coverage. The 2.0 family returned "limit: 0" on
// fresh AI Studio projects (probably geo-restricted) and the
// bare 1.5-flash alias was retired from the v1beta endpoint
// this SDK targets.
private const val MODEL_NAME = "gemini-2.5-flash"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.plainstudio.stackcasino.domain.assistant

/**
* Single turn in a Nep conversation. The data layer translates this
* to whatever the underlying provider (Gemini today, anything else
* tomorrow) needs to model multi-turn context.
*/
data class ChatTurn(
val role: Role,
val text: String,
)

enum class Role { User, Nep }

/**
* Domain boundary for the in-app casino assistant. The implementation
* decides which provider, which model, and how to keep the persona
* consistent; the ViewModel only sees turns in / response text out.
*/
interface AssistantRepository {
/**
* Sends [userMessage] to Nep with [history] as prior context and
* returns Nep's reply.
*
* Failures (network, quota, missing API key, content policy) are
* surfaced as a failed [Result] so the caller can choose how to
* render them without exception-bubbling across the layer boundary.
*/
suspend fun sendMessage(
history: List<ChatTurn>,
userMessage: String,
): Result<String>
}
Loading
Loading