1use anyhow::{anyhow, bail, Context, Result};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Default, Clone)]
13pub struct ScaffoldOptions {
14 pub parent_dir: Option<PathBuf>,
16}
17
18pub fn scaffold_bot(name: &str, opts: &ScaffoldOptions) -> Result<PathBuf> {
19 if !is_valid_project_name(name) {
20 bail!(
21 "Invalid project name {name:?}. Use ASCII letters, digits, underscores, and dashes only."
22 );
23 }
24 let parent = match &opts.parent_dir {
25 Some(p) => p.clone(),
26 None => std::env::current_dir().context("could not resolve current directory")?,
27 };
28 let project_dir = parent.join(name);
29
30 if project_dir.exists() {
31 return Err(anyhow!(
32 "Directory already exists: {}",
33 project_dir.display()
34 ));
35 }
36
37 fs::create_dir_all(&project_dir)
38 .with_context(|| format!("creating {}", project_dir.display()))?;
39 fs::create_dir_all(project_dir.join("src"))
40 .with_context(|| format!("creating {}/src", project_dir.display()))?;
41
42 write_file(&project_dir.join("Cargo.toml"), &cargo_toml(name))?;
43 write_file(&project_dir.join("src").join("main.rs"), MAIN_RS_TEMPLATE)?;
44 write_file(&project_dir.join(".gitignore"), GITIGNORE_TEMPLATE)?;
45 write_file(&project_dir.join(".dockerignore"), DOCKERIGNORE_TEMPLATE)?;
46 write_file(&project_dir.join("README.md"), &readme_template(name))?;
47 write_file(&project_dir.join("Dockerfile"), DOCKERFILE_TEMPLATE)?;
48
49 Ok(project_dir)
50}
51
52fn write_file(path: &Path, contents: &str) -> Result<()> {
53 fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
54}
55
56fn is_valid_project_name(name: &str) -> bool {
57 !name.is_empty()
58 && name
59 .chars()
60 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
61}
62
63fn cargo_toml(name: &str) -> String {
64 format!(
65 r#"[package]
66name = "{name}"
67version = "0.1.0"
68edition = "2021"
69
70# The release binary is named `bot` so the IP-protected Dockerfile can
71# `cp target/release/bot /build/bot` without knowing the package name.
72[[bin]]
73name = "bot"
74path = "src/main.rs"
75
76[dependencies]
77chipzen-bot = "0.3"
78tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
79
80[profile.release]
81opt-level = 3
82lto = "thin"
83strip = "symbols"
84codegen-units = 1
85"#
86 )
87}
88
89const MAIN_RS_TEMPLATE: &str = r#"//! Chipzen starter bot — replace `decide` with your strategy.
90//! The SDK handles WebSocket, handshake, ping/pong, retries, and reconnect.
91//!
92//! Two ways to play:
93//! * Default (containerized / direct match URL): the platform's executor
94//! runs this binary in a container and injects CHIPZEN_WS_URL +
95//! CHIPZEN_TOKEN. `cargo run` with those set, or pass the URL as the
96//! first argument, to test locally.
97//! * `bot run-external [--env staging] [--max-matches 1]`: external-API
98//! remote-play — run on YOUR machine with a `cz_extbot_` token and let
99//! the platform match + dispatch you. Reads token/bot_id/url from a
100//! `chipzen.toml` (or pass --token / --bot-id). See the SDK docs.
101
102use chipzen_bot::{
103 run_bot, run_external_cli, Action, Bot, EnvName, GameState, RunBotOptions, RunExternalArgs,
104};
105
106struct MyBot;
107
108impl Bot for MyBot {
109 fn decide(&mut self, state: &GameState) -> Action {
110 // Return one of: Action::Fold, Action::Check, Action::Call,
111 // Action::Raise(amount), Action::AllIn. The chosen action's
112 // wire-form must be in state.valid_actions; raises must satisfy
113 // state.min_raise <= amount <= state.max_raise.
114 if state.valid_actions.iter().any(|a| a == "check") {
115 Action::Check
116 } else {
117 Action::Fold
118 }
119 }
120}
121
122#[tokio::main]
123async fn main() -> Result<(), chipzen_bot::Error> {
124 let args: Vec<String> = std::env::args().skip(1).collect();
125
126 if args.first().map(String::as_str) == Some("run-external") {
127 // External-API remote-play: your machine connects to the lobby and
128 // the platform matches + dispatches you. Flags mirror the Python CLI.
129 return run_external_mode(&args[1..]).await.map(|_| ());
130 }
131
132 // Default: containerized / direct-match path. The platform injects
133 // CHIPZEN_WS_URL and CHIPZEN_TOKEN (or CHIPZEN_TICKET) at launch; for
134 // local testing set them yourself or pass the URL as the first argument.
135 let url = args
136 .first()
137 .cloned()
138 .or_else(|| std::env::var("CHIPZEN_WS_URL").ok())
139 .unwrap_or_else(|| {
140 eprintln!("error: CHIPZEN_WS_URL not set and no URL passed on the command line");
141 std::process::exit(1);
142 });
143
144 let options = RunBotOptions {
145 token: std::env::var("CHIPZEN_TOKEN").ok(),
146 ticket: std::env::var("CHIPZEN_TICKET").ok(),
147 ..Default::default()
148 };
149
150 run_bot(&url, MyBot, options).await.map(|_| ())
151}
152
153/// Parse `run-external` flags and play. A fresh `MyBot` per match.
154async fn run_external_mode(flags: &[String]) -> Result<(), chipzen_bot::Error> {
155 let mut args = RunExternalArgs::new();
156 let mut i = 0;
157 while i < flags.len() {
158 match flags[i].as_str() {
159 "--env" => {
160 i += 1;
161 args.env = flags.get(i).and_then(|e| EnvName::parse(e));
162 }
163 "--token" => {
164 i += 1;
165 args.token = flags.get(i).cloned();
166 }
167 "--bot-id" => {
168 i += 1;
169 args.bot_id = flags.get(i).cloned();
170 }
171 "--max-matches" => {
172 i += 1;
173 args.max_matches = flags.get(i).and_then(|v| v.parse().ok());
174 }
175 "--no-safe-mode" => args.safe_mode = false,
176 other => eprintln!("warning: ignoring unknown flag {other:?}"),
177 }
178 i += 1;
179 }
180 let results = run_external_cli(|| MyBot, args).await?;
181 eprintln!("played {} match(es)", results.len());
182 Ok(())
183}
184"#;
185
186const GITIGNORE_TEMPLATE: &str = "target/\nCargo.lock\n.env\n.env.*\n.DS_Store\n";
187
188const DOCKERIGNORE_TEMPLATE: &str =
189 "target/\n.git/\n.gitignore\n.env\n.env.*\n*.md\nREADME*\nLICENSE*\n.DS_Store\n";
190
191const DOCKERFILE_TEMPLATE: &str = r#"# syntax=docker/dockerfile:1.7
195#
196# IP-protected Chipzen Rust bot image.
197#
198# Multi-stage build that compiles the bot to a single, statically-
199# linked release binary in the builder stage, then ships only that
200# binary in the runtime stage. The runtime image contains no readable
201# Rust source for your strategy code — only the stripped binary.
202#
203# See ../../IP-PROTECTION.md for what this protects (and what it doesn't).
204#
205# Build: docker build -t my-bot:test .
206# Export: docker save my-bot:test | gzip > my-bot.tar.gz
207#
208# Build context for this directory should be small (Cargo.toml +
209# src/ + this file). The .dockerignore alongside this file keeps the
210# target/ build cache and editor metadata out.
211
212# -----------------------------------------------------------------------------
213# Stage 1: cargo build --release. The .rs source lives only in this stage and
214# is discarded before the runtime stage starts.
215# -----------------------------------------------------------------------------
216# Base pinned by tag — Dependabot can rotate to digest pinning later. Tag:
217# rust:1-slim (debian-bookworm-based; the runtime stage below also uses
218# debian-bookworm so glibc + libssl line up for the compiled binary).
219FROM rust:1-slim AS builder
220
221WORKDIR /build
222
223# Build tools needed by tokio-tungstenite's `native-tls` feature
224# (links against system openssl). pkg-config tells the openssl-sys
225# build script where libssl + libcrypto live.
226RUN apt-get update \
227 && apt-get install -y --no-install-recommends \
228 pkg-config \
229 libssl-dev \
230 && rm -rf /var/lib/apt/lists/*
231
232# Bring in the bot source + manifest. Only these are copied — keep
233# the build context narrow so the .dockerignore is the only allowlist.
234COPY Cargo.toml ./
235COPY src/ ./src/
236
237# Build the release binary. The starter's [profile.release] is already
238# tuned for a small, symbol-stripped binary (lto=thin, opt-level=3,
239# codegen-units=1).
240RUN cargo build --release --bin bot \
241 && cp target/release/bot /build/bot \
242 && rm -rf src/ target/ Cargo.toml
243
244# -----------------------------------------------------------------------------
245# Stage 2: Runtime. Only the compiled binary + ENTRYPOINT.
246# No .rs source for the bot's strategy is present.
247# -----------------------------------------------------------------------------
248# Base pinned by tag — Dependabot can rotate to digest pinning later. Tag:
249# debian:12-slim. Matches the glibc the builder stage links against and
250# carries libssl3 + ca-certs for outbound TLS to the platform's wss://.
251FROM debian:12-slim
252
253RUN apt-get update \
254 && apt-get install -y --no-install-recommends \
255 ca-certificates \
256 libssl3 \
257 dumb-init \
258 && rm -rf /var/lib/apt/lists/*
259
260WORKDIR /bot
261
262# Copy ONLY the compiled binary from the builder stage.
263COPY --from=builder /build/bot /bot/bot
264RUN chmod +x /bot/bot
265
266# Run as non-root (defense in depth — the platform also applies seccomp
267# and cap-drop on top of this).
268RUN groupadd --system --gid 10001 bot \
269 && useradd --system --uid 10001 --gid bot --home-dir /bot --shell /usr/sbin/nologin bot \
270 && chown -R bot:bot /bot
271USER 10001
272
273ENTRYPOINT ["dumb-init", "/bot/bot"]
274"#;
275pub const _DOCKERFILE_TEMPLATE_FOR_TEST: &str = DOCKERFILE_TEMPLATE;
276
277fn readme_template(name: &str) -> String {
278 format!(
279 r#"# {name}
280
281A poker bot for the [Chipzen](https://chipzen.ai) platform.
282
283## Quick start
284
285```bash
286cargo build
287```
288
289Edit `src/main.rs` to implement your strategy in the `decide` method.
290
291Validate before uploading:
292
293```bash
294chipzen-sdk validate .
295```
296
297Build and export the upload tarball:
298
299```bash
300docker build -t {name}:v1 .
301docker save {name}:v1 | gzip > {name}.tar.gz
302```
303
304Then upload via the Chipzen platform UI.
305"#
306 )
307}