diff --git a/scripts/run/dist.sh b/scripts/run/dist.sh index eb4bc749..d4553e0a 100755 --- a/scripts/run/dist.sh +++ b/scripts/run/dist.sh @@ -34,6 +34,9 @@ Distributed test/train fleet (DigitalOcean). Set TF_VAR_do_token first. ./run dist:up [size] [region] e.g. ./run dist:up 10 ./run dist:sim [turn_limit] [--destroy-after] ./run dist:train [--destroy-after] + ./run dist:test cargo test --workspace on a worker + ./run dist:build cargo build + wasm on a worker (wasm rsync'd back) + ./run dist:sync [ref] git pull + rebuild gdext on live workers ./run dist:down EOF } @@ -181,3 +184,71 @@ cmd_dist_train() { $destroy && { echo "--destroy-after → tearing down"; cmd_dist_down; } [ "$fail" -eq 0 ] } + +# ── compute offload (single worker) ────────────────────────────────────────── +# Run heavy build/test compute on a DO worker instead of plum (M2 Air). Workers +# already carry the toolchain (golden image) + repo (cloud-init git pull). + +_dist_first_host() { + local inv + inv="$(_dist_repo_root)/.local/fleet/inventory" + [ -f "$inv" ] || return 1 + _dist_read_hosts "$inv" | head -1 +} + +cmd_dist_sync() { + # Pull the given ref on every live worker + rebuild the GDExtension, so a + # mid-session code change reaches the fleet without an image rebuild. + local ref="${1:-main}" + local root inv host + root="$(_dist_repo_root)" + inv="$root/.local/fleet/inventory" + [ -f "$inv" ] || { echo "no fleet — run ./run dist:up first" >&2; return 1; } + local pids=() p fail=0 + while IFS= read -r host; do + echo "[$host] sync → $ref" + ssh -n -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$host" " + set -e + cd ~/Code/@projects/@magic-civilization + git fetch --depth=1 origin '$ref' && git reset --hard FETCH_HEAD + cd src/simulator && . ~/.cargo/env && bash build-gdext.sh + " & + pids+=($!) + done < <(_dist_read_hosts "$inv") + for p in "${pids[@]}"; do wait "$p" || fail=$(( fail + 1 )); done + [ "$fail" -eq 0 ] && echo "synced all workers to $ref" || { echo "$fail worker(s) failed sync" >&2; return 1; } +} + +cmd_dist_test() { + # Offload the Rust test suite to one fast worker (slow on the M2 Air). + local host repo + host="$(_dist_first_host)" || { echo "no fleet — run ./run dist:up 1 c-8 first" >&2; return 1; } + repo="Code/@projects/@magic-civilization" + echo "running cargo tests on $host ..." + ssh -n -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$host" " + set -e + cd ~/$repo/src/simulator && . ~/.cargo/env + if command -v cargo-nextest >/dev/null 2>&1; then cargo nextest run --workspace; else cargo test --workspace; fi + " +} + +cmd_dist_build() { + # Offload the workspace build for fast compile feedback, and bring back the + # platform-independent WASM artifact. The native .so is linux-only and stays + # on the worker (plum builds its own macOS .dylib locally). + local host root repo + host="$(_dist_first_host)" || { echo "no fleet — run ./run dist:up 1 first" >&2; return 1; } + root="$(_dist_repo_root)" + repo="Code/@projects/@magic-civilization" + echo "building workspace + wasm on $host ..." + ssh -n -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$host" " + set -e + cd ~/$repo/src/simulator && . ~/.cargo/env + cargo build --workspace + bash build-wasm.sh + " + echo "fetching wasm artifact → plum ..." + mkdir -p "$root/.local/build/wasm" + rsync -az "$host:~/$repo/.local/build/wasm/" "$root/.local/build/wasm/" 2>/dev/null \ + && echo "wasm → .local/build/wasm/" || echo "note: no wasm at .local/build/wasm/ on worker" +} diff --git a/scripts/run/forge.sh b/scripts/run/forge.sh new file mode 100755 index 00000000..4ad1b20d --- /dev/null +++ b/scripts/run/forge.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Forgejo origin lifecycle on DigitalOcean. Sourced by ./run (defines cmd_forge_*). +# ./run forge:down stop service, snapshot, destroy droplet (~$6/mo -> ~$0.30/mo idle) +# ./run forge:up recreate from newest snapshot, refresh ~/.vault/mc_forge_creds +# +# DO bills powered-off droplets; only destroy stops billing, so "down" = +# snapshot + destroy and "up" = create-from-snapshot. The droplet gets a NEW ip +# each time, so forge:up refreshes the vault creds file (the single source of +# truth for the forge URL). + +_FORGE_TAG="forgejo" +_FORGE_SIZE="s-1vcpu-1gb" +_FORGE_REGION="nyc3" +_FORGE_KEY_ID="57416789" +_FORGE_SNAP_PREFIX="mc-forge-snap" +_VAULT_PAT="$HOME/.vault/do_pat_mc" +_VAULT_CREDS="$HOME/.vault/mc_forge_creds" + +_forge_pat() { cat "$_VAULT_PAT" 2>/dev/null; } + +_forge_curl() { + # _forge_curl METHOD PATH [JSON-body] + local method="$1" path="$2" data="${3:-}" pat + pat="$(_forge_pat)" + if [ -n "$data" ]; then + curl -s -X "$method" -H "Authorization: Bearer $pat" -H "Content-Type: application/json" -d "$data" "https://api.digitalocean.com/v2${path}" + else + curl -s -X "$method" -H "Authorization: Bearer $pat" "https://api.digitalocean.com/v2${path}" + fi +} + +_forge_droplet_id() { + _forge_curl GET "/droplets?tag_name=${_FORGE_TAG}" \ + | python3 -c "import sys,json;d=json.load(sys.stdin).get('droplets',[]);print(d[0]['id'] if d else '')" +} + +_forge_project_id() { + _forge_curl GET "/projects" \ + | python3 -c "import sys,json;print(next((p['id'] for p in json.load(sys.stdin)['projects'] if p['name']=='mc:dev'),''))" +} + +_forge_wait_action() { + local aid="$1" st + for _ in $(seq 1 120); do + st=$(_forge_curl GET "/actions/$aid" | python3 -c "import sys,json;print(json.load(sys.stdin)['action']['status'])" 2>/dev/null) + [ "$st" = completed ] && return 0 + [ "$st" = errored ] && { echo "action $aid errored" >&2; return 1; } + sleep 5 + done + echo "action $aid timed out" >&2 + return 1 +} + +cmd_forge() { + cat <<'EOF' +Forgejo origin lifecycle (DigitalOcean). Needs ~/.vault/do_pat_mc. + ./run forge:down stop + snapshot + destroy (~$6/mo -> ~$0.30/mo idle) + ./run forge:up restore from newest snapshot, refresh vault creds +EOF +} + +cmd_forge_down() { + local id ip aid snap + id="$(_forge_droplet_id)" + [ -n "$id" ] || { echo "no live mc-forge droplet (already down?)" >&2; return 1; } + ip=$(grep -E '^FORGE_IP=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2) + echo "[1/4] stopping forgejo service on ${ip:-?}" + [ -n "$ip" ] && ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i ~/.ssh/id_mc_fleet root@"$ip" 'systemctl stop forgejo' 2>/dev/null || true + echo "[2/4] powering off droplet $id" + aid=$(_forge_curl POST "/droplets/$id/actions" '{"type":"power_off"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['action']['id'])") + _forge_wait_action "$aid" || return 1 + snap="${_FORGE_SNAP_PREFIX}-$(date +%Y%m%d%H%M%S)" + echo "[3/4] snapshotting -> $snap" + aid=$(_forge_curl POST "/droplets/$id/actions" "{\"type\":\"snapshot\",\"name\":\"$snap\"}" | python3 -c "import sys,json;print(json.load(sys.stdin)['action']['id'])") + _forge_wait_action "$aid" || return 1 + echo "[4/4] destroying droplet $id" + _forge_curl DELETE "/droplets/$id" >/dev/null + echo "forge down — snapshot $snap kept (~\$0.30/mo). './run forge:up' to restore." +} + +cmd_forge_up() { + local snapid did ip code projid admin_user admin_pass + snapid=$(_forge_curl GET "/snapshots?resource_type=droplet" \ + | python3 -c "import sys,json;s=[x for x in json.load(sys.stdin)['snapshots'] if x['name'].startswith('${_FORGE_SNAP_PREFIX}')];s.sort(key=lambda x:x['created_at']);print(s[-1]['id'] if s else '')") + [ -n "$snapid" ] || { echo "no ${_FORGE_SNAP_PREFIX}-* snapshot found" >&2; return 1; } + echo "[1/4] creating droplet from snapshot $snapid" + did=$(_forge_curl POST "/droplets" "{\"name\":\"mc-forge\",\"region\":\"${_FORGE_REGION}\",\"size\":\"${_FORGE_SIZE}\",\"image\":${snapid},\"ssh_keys\":[${_FORGE_KEY_ID}],\"tags\":[\"magic-civilization\",\"${_FORGE_TAG}\"]}" \ + | python3 -c "import sys,json;d=json.load(sys.stdin).get('droplet');print(d['id'] if d else '')") + [ -n "$did" ] || { echo "create failed" >&2; return 1; } + echo "[2/4] waiting for active + ip (droplet $did)" + for _ in $(seq 1 40); do + ip=$(_forge_curl GET "/droplets/$did" | python3 -c "import sys,json;d=json.load(sys.stdin)['droplet'];ips=[n['ip_address'] for n in d['networks']['v4'] if n['type']=='public'];print(ips[0] if ips and d['status']=='active' else '')") + [ -n "$ip" ] && break + sleep 8 + done + [ -n "$ip" ] || { echo "droplet never reported an ip" >&2; return 1; } + echo "[3/4] waiting for forgejo http at $ip:3000" + for _ in $(seq 1 30); do code=$(curl -s -o /dev/null -m 5 -w "%{http_code}" "http://$ip:3000/" 2>/dev/null); [ "$code" = 200 ] && break; sleep 4; done + projid="$(_forge_project_id)" + [ -n "$projid" ] && _forge_curl POST "/projects/$projid/resources" "{\"resources\":[\"do:droplet:$did\"]}" >/dev/null + echo "[4/4] refreshing $_VAULT_CREDS with new ip" + admin_user=$(grep -E '^ADMIN_USER=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2); admin_user=${admin_user:-mcadmin} + admin_pass=$(grep -E '^ADMIN_PASS=' "$_VAULT_CREDS" 2>/dev/null | cut -d= -f2) + umask 177 + cat > "$_VAULT_CREDS" <, + /// HP healed on promotion, as a percentage of max HP. + heal_on_promote_percent: f32, +} -/// HP healed on promotion (percentage of max HP). -const HEAL_ON_PROMOTE_FRACTION: f32 = 0.50; +/// Load + cache the promotion tuning. The JSON is compiled in via `include_str!` +/// (WASM- and GDExtension-safe — no filesystem access at runtime) and parsed +/// once into a process-wide `OnceLock`, mirroring `mc_comms::config`. +fn promotion_config() -> &'static PromotionConfig { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + const JSON: &str = + include_str!("../../../../../public/resources/promotions/promotions.json"); + let value: serde_json::Value = + serde_json::from_str(JSON).expect("promotions.json must parse as valid JSON"); + let xp_thresholds = value + .get("xp_thresholds") + .and_then(|v| serde_json::from_value::>(v.clone()).ok()) + .expect("promotions.json must define an xp_thresholds array"); + assert!( + !xp_thresholds.is_empty(), + "promotions.json xp_thresholds must be non-empty" + ); + let heal_on_promote_percent = value + .get("heal_on_promote_percent") + .and_then(serde_json::Value::as_f64) + .map(|p| p as f32) + .expect("promotions.json must define heal_on_promote_percent"); + PromotionConfig { + xp_thresholds, + heal_on_promote_percent, + } + }) +} /// XP gained from a combat engagement. Scales with relative strength difference. /// `strength_ratio` = defender_effective / attacker_effective. @@ -25,11 +62,12 @@ pub fn xp_from_combat(base_xp: i32, strength_ratio: f32) -> i32 { /// Check if a unit qualifies for promotion at its current XP and level. /// Returns the next promotion level if qualified, None otherwise. pub fn check_promotion(current_xp: i32, current_level: i32) -> Option { + let thresholds = &promotion_config().xp_thresholds; let idx = current_level as usize; - if idx >= XP_THRESHOLDS.len() { + if idx >= thresholds.len() { return None; // Max level reached } - if current_xp >= XP_THRESHOLDS[idx] { + if current_xp >= thresholds[idx] { Some(current_level + 1) } else { None @@ -38,18 +76,19 @@ pub fn check_promotion(current_xp: i32, current_level: i32) -> Option { /// HP healed when a unit promotes. pub fn heal_on_promote(max_hp: i32) -> i32 { - (max_hp as f32 * HEAL_ON_PROMOTE_FRACTION).round() as i32 + let percent = promotion_config().heal_on_promote_percent; + (max_hp as f32 * percent / 100.0).round() as i32 } /// Maximum promotion level. pub fn max_promotion_level() -> i32 { - XP_THRESHOLDS.len() as i32 + promotion_config().xp_thresholds.len() as i32 } /// XP threshold for a given promotion level. /// Returns None if the level is beyond max. pub fn xp_threshold(level: i32) -> Option { - XP_THRESHOLDS.get(level as usize).copied() + promotion_config().xp_thresholds.get(level as usize).copied() } /// Validate that a promotion choice is compatible with existing promotions. @@ -128,21 +167,25 @@ mod tests { #[test] fn promotion_at_threshold() { - assert_eq!(check_promotion(10, 0), Some(1)); - assert_eq!(check_promotion(9, 0), None); + // Canonical thresholds from promotions.json: [15, 30, 45, 60]. + assert_eq!(check_promotion(15, 0), Some(1)); + assert_eq!(check_promotion(14, 0), None); assert_eq!(check_promotion(30, 1), Some(2)); } #[test] fn max_level_no_more_promotions() { + // Four promotion-tree levels → four thresholds. let max = max_promotion_level(); + assert_eq!(max, 4); assert_eq!(check_promotion(9999, max), None); } #[test] - fn heal_on_promote_half_hp() { - assert_eq!(heal_on_promote(60), 30); - assert_eq!(heal_on_promote(100), 50); + fn heal_on_promote_uses_json_percent() { + // promotions.json heal_on_promote_percent = 30. + assert_eq!(heal_on_promote(60), 18); + assert_eq!(heal_on_promote(100), 30); } #[test]