//! JavaScript bindings of tokenling-core for the browser page and its brain //! worker. `web/core.ts` wraps these exports with types. No storage, network //! or model access happens here; the page passes in times and saves. use serde::{Serialize, de::DeserializeOwned}; use serde_json::json; use tokenling_core::{ body, engine::{self, Command, Dice}, save, turn, types::{PetState, Stats, WorldState}, world, }; use wasm_bindgen::prelude::*; fn to_js(value: &T) -> Result { Ok(value.serialize(&serde_wasm_bindgen::Serializer::json_compatible())?) } fn from_js(value: JsValue) -> Result { Ok(serde_wasm_bindgen::from_value(value)?) } fn sorted(values: &[&str]) -> Vec { let mut values: Vec<_> = values.iter().map(|v| v.to_string()).collect(); values.sort(); values } /// `Math.random`, or test overrides for every roll or only wake rolls. struct JsDice<'a> { rolls: Option<&'a js_sys::Function>, wake: Option<&'a js_sys::Function>, } fn call(f: Option<&js_sys::Function>) -> Option { f?.call0(&JsValue::NULL).ok()?.as_f64() } impl Dice for JsDice<'_> { fn roll(&mut self) -> f64 { call(self.rolls).unwrap_or_else(js_sys::Math::random) } fn wake(&mut self) -> f64 { call(self.wake).unwrap_or_else(|| self.roll()) } } /// The closed word lists, as `train/world.py` names them. #[wasm_bindgen] pub fn lists() -> Result { to_js(&json!({ "STAGES": world::STAGES, "PERSONALITIES": world::PERSONALITIES, "FOODS": world::FOODS, "GIFTS": world::GIFTS, "DREAMS": world::DREAMS, "EMOTES": world::EMOTES, "WANTS": world::WANTS, "WALKS": world::WALKS, "DOODLES": world::DOODLES, "WEATHER_DAY": world::WEATHER_DAY, "WEATHER_NIGHT": world::WEATHER_NIGHT, "TOYS": world::TOYS, "COLORS": world::COLORS, "EFFECT_TOOLS": sorted(world::EFFECT_TOOLS), "LOOKUP_TOOLS": sorted(world::LOOKUP_TOOLS), "ANSWER_TOOLS": sorted(world::ANSWER_TOOLS), })) } #[wasm_bindgen] pub fn header(kind: &str, world_state: JsValue) -> Result { if !["chat", "diary"].contains(&kind) { return Err(JsError::new("unknown prompt kind")); } Ok(world::header(kind, &from_js::(world_state)?)) } #[wasm_bindgen(js_name = toolResult)] pub fn tool_result(world_state: JsValue, name: &str, arg: &str) -> Result, JsError> { Ok(world::tool_result(&from_js(world_state)?, name, arg)) } #[wasm_bindgen] pub fn recall(memories: Vec, query: &str) -> String { world::recall(&memories, query) } #[wasm_bindgen(js_name = partOfDay)] pub fn part_of_day(hour: u32) -> String { world::part_of_day(hour).into() } #[wasm_bindgen(js_name = levelWords)] pub fn level_words(stats: JsValue) -> Result { let [tummy, fun, energy, clean] = world::level_words(&from_js::(stats)?); to_js(&json!({"tummy": tummy, "fun": fun, "energy": energy, "clean": clean})) } #[wasm_bindgen(js_name = wakeChance)] pub fn wake_chance(energy: f64, kind: &str, n: u32) -> f64 { world::wake_chance(energy, kind, n) } #[wasm_bindgen(js_name = dreamThing)] pub fn dream_thing(item: &str) -> String { world::dream_thing(item) } /// An app event line by name (`offerFood`, `pat`, `woke`...), for parity checks. #[wasm_bindgen] pub fn event(name: &str, owner: &str, pet: &str, arg: &str) -> Result { use world::events as e; Ok(match name { "offerFood" => e::offer_food(owner, pet, arg), "offerBath" => e::offer_bath(owner, pet), "offerMedicine" => e::offer_medicine(owner, pet), "offerCatch" => e::offer_catch(owner, pet), "caught" => e::caught( owner, pet, arg.parse().map_err(|_| JsError::new("bad count"))?, ), "pat" => e::pat(owner, pet), "lights" => e::lights(owner, arg == "on"), "woke" => e::woke(pet), "pooped" => e::pooped(pet), "cleaned" => e::cleaned(owner), "back" => e::back(owner, arg), "grew" => e::grew(pet, arg), _ => return Err(JsError::new("unknown event")), }) } #[wasm_bindgen] pub fn tags(text: &str) -> Result { to_js(&world::tags(text)) } #[wasm_bindgen(js_name = cleanText)] pub fn clean_text(text: &str) -> String { world::clean_text(text) } #[wasm_bindgen] pub fn silent(text: &str) -> bool { world::silent(text) } #[wasm_bindgen(js_name = notCute)] pub fn not_cute(text: &str) -> bool { world::not_cute(text) } #[wasm_bindgen(js_name = cleanName)] pub fn clean_name(name: &str, max: usize) -> String { world::clean_name(name, max) } #[wasm_bindgen(js_name = cleanMessage)] pub fn clean_message(text: &str) -> String { world::clean_message(text) } /// The stop rule for generated text: `{reason, text}` or undefined. #[wasm_bindgen] pub fn stop(mode: &str, text: &str) -> Result { match world::stop(mode, text) { Some((reason, text)) => to_js(&json!({"reason": reason, "text": text})), None => Ok(JsValue::UNDEFINED), } } /// Fits a prompt into the context; `count` is the tokenizer's token count. #[wasm_bindgen] pub fn fit( header: &str, lines: Vec, prefix: &str, max_new: usize, ctx: usize, count: &js_sys::Function, ) -> Result { let mut count = |text: &str| -> Result { count .call1(&JsValue::NULL, &text.into()) .map_err(|e| format!("{e:?}"))? .as_f64() .map(|n| n as usize) .ok_or_else(|| "the token count is not a number".into()) }; world::fit(header, &lines, prefix, max_new, ctx, &mut count).map_err(|e| JsError::new(&e)) } #[wasm_bindgen(js_name = offerThing)] pub fn offer_thing(offer: JsValue) -> Result { Ok(engine::offer_thing(&from_js(offer)?)) } /// Growth stages with the age each starts at, oldest first. #[wasm_bindgen(js_name = stageAges)] pub fn stage_ages() -> Result { to_js(&body::STAGE_AGES) } #[wasm_bindgen(js_name = stageFor)] pub fn stage_for(age: f64) -> String { body::stage_for(age).into() } #[wasm_bindgen(js_name = freshSave)] pub fn fresh_save(now: f64, time_zone: &str) -> Result { to_js(&save::fresh(now, time_zone)) } /// Reads a save: `{state}`, or `{error, newer}` for a save this page must keep. #[wasm_bindgen(js_name = parseSave)] pub fn parse_save(raw: &str, now: f64, time_zone: &str) -> Result { match save::parse_save(raw, now, time_zone) { Ok(state) => to_js(&json!({ "state": state })), Err(e) => to_js(&json!({ "error": e.message, "newer": e.newer })), } } #[wasm_bindgen(js_name = saveVersion)] pub fn save_version() -> u32 { save::VERSION } /// Runs the body on a copy of `state`; returns `{state, events}`. #[wasm_bindgen(js_name = simulateBody)] pub fn simulate_body(state: JsValue, from: f64, to: f64, quiet: bool) -> Result { let mut state: PetState = from_js(state)?; let events = body::simulate_body(&mut state, from, to, quiet, &mut js_sys::Math::random); to_js(&json!({ "state": state, "events": events })) } /// A running pet. The browser keeps one for the save in this tab. #[wasm_bindgen] pub struct Engine { inner: engine::Engine, seed: String, overrides: Overrides, seen: u64, } #[derive(Default)] struct Overrides { rolls: Option, wake: Option, } impl Overrides { fn dice(&self) -> JsDice<'_> { JsDice { rolls: self.rolls.as_ref(), wake: self.wake.as_ref(), } } } #[wasm_bindgen] impl Engine { #[wasm_bindgen(constructor)] pub fn new(state: JsValue, now: f64, seed: String) -> Result { let state: PetState = from_js(state)?; state.validate().map_err(|e| JsError::new(&e.0))?; Ok(Self { inner: engine::Engine::new(state, now), seed, overrides: Overrides::default(), seen: 0, }) } /// Replaces the save (an import, a transfer or a server snapshot). Turn /// requests, disturbances and an accepted game belong to the old copy. pub fn replace(&mut self, state: JsValue, now: f64) -> Result<(), JsError> { let state: PetState = from_js(state)?; state.validate().map_err(|e| JsError::new(&e.0))?; let seq = self.inner.effect_seq; let revision = self.inner.revision + 1; let context = self.inner.context_revision + 1; self.inner = engine::Engine::new(state, now); (self.inner.effect_seq, self.seen) = (seq, seq); self.inner.revision = revision; self.inner.context_revision = context; Ok(()) } /// Replaces the save but keeps the turn queue and effects (debug edits). pub fn edit(&mut self, state: JsValue) -> Result<(), JsError> { let state: PetState = from_js(state)?; state.validate().map_err(|e| JsError::new(&e.0))?; self.inner.state = state; self.inner.revision += 1; self.inner.context_revision += 1; Ok(()) } pub fn state(&self) -> Result { to_js(&self.inner.state) } #[wasm_bindgen(js_name = stateJson)] pub fn state_json(&self) -> Result { Ok(serde_json::to_string(&self.inner.state)?) } /// The fields a body tick changes, for cheap refreshes between full reads. pub fn body(&self) -> Result { let s = &self.inner.state; to_js(&json!({ "stats": s.stats, "sick": s.sick, "poop": s.poop, "asleep": s.asleep, "lightsOff": s.lights_off, "grimeSince": s.grime_since, "nextPoop": s.next_poop, "lastTick": s.last_tick, "lastActive": s.last_active, "wokeAt": s.woke_at, })) } #[wasm_bindgen(getter)] pub fn revision(&self) -> f64 { self.inner.revision as f64 } #[wasm_bindgen(getter, js_name = contextRevision)] pub fn context_revision(&self) -> f64 { self.inner.context_revision as f64 } #[wasm_bindgen(getter)] pub fn pending(&self) -> Option { self.inner.pending.clone() } #[wasm_bindgen(getter)] pub fn game(&self) -> Result { to_js(&self.inner.game) } /// Disturbances that still count at `now` (debug display of the next roll). #[wasm_bindgen(js_name = disturbancesAt)] pub fn disturbances_at(&self, now: f64) -> u32 { self.inner.disturbances_at(now) } #[wasm_bindgen(getter)] pub fn disturbances(&self) -> Result { to_js(&json!({"count": self.inner.disturbance_count, "last": self.inner.disturbance_last})) } /// Runs the body to `now`. Returns false when only `body()` fields changed. pub fn advance(&mut self, now: f64, returning: bool) -> bool { let before = ( self.inner.revision, self.inner.game.is_some(), self.inner.state.offer.is_some(), ); let mut dice = self.overrides.dice(); self.inner.advance(now, &self.seed, returning, &mut dice); self.inner.revision > before.0 + 1 || self.inner.game.is_some() != before.1 || self.inner.state.offer.is_some() != before.2 } /// Restarts the body clock, so time spent elsewhere is not replayed. #[wasm_bindgen(js_name = resetClock)] pub fn reset_clock(&mut self, now: f64) { self.inner.state.last_tick = now; self.inner.revision += 1; } /// An owner command, the same JSON the host accepts. Throws the refusal. pub fn command(&mut self, command: JsValue, now: f64) -> Result<(), JsError> { let command: Command = from_js(command)?; let mut dice = self.overrides.dice(); self.inner .command(command, now, &self.seed, &mut dice) .map_err(|e| JsError::new(&e.0)) } #[wasm_bindgen(js_name = layEgg)] pub fn lay_egg(&mut self, owner: &str, now: f64) -> Result<(), JsError> { self.inner .lay_egg(owner, now) .map_err(|e| JsError::new(&e.0)) } /// Device preferences in `settings` (not `chatter`, which is a command). #[wasm_bindgen(js_name = setPreference)] pub fn set_preference(&mut self, key: &str, value: JsValue) -> Result<(), JsError> { if key == "chatter" { return Err(JsError::new("chatter is a pet setting")); } let value: serde_json::Value = from_js(value)?; self.inner.state.settings.extra.insert(key.into(), value); self.inner.revision += 1; Ok(()) } /// Applies a line's tags as if the pet had said it (debug checks). #[wasm_bindgen(js_name = applyLine)] pub fn apply_line(&mut self, line: &str, now: f64) { let mut dice = self.overrides.dice(); self.inner.apply_line(line, now, &self.seed, &mut dice); } pub fn world(&self, now: f64) -> Result { if self.inner.state.pet.is_none() { return Ok(JsValue::NULL); } to_js(&self.inner.world(now, &self.seed)) } pub fn weather(&self, now: f64) -> String { body::weather_at(&self.inner.state, now, &self.seed) } #[wasm_bindgen(js_name = dayNum)] pub fn day_num(&self, now: f64) -> f64 { body::day_num(&self.inner.state, now) as f64 } #[wasm_bindgen(js_name = wantTurn)] pub fn want_turn(&mut self, kind: &str) { self.inner .want_turn(if kind == "idle" { "idle" } else { "react" }); } /// When the next idle turn may come. #[wasm_bindgen(js_name = idleAt)] pub fn idle_at(&mut self, at: f64) { self.inner.next_idle = at; } #[wasm_bindgen(js_name = chooseTask)] pub fn choose_task(&self, now: f64, background: bool) -> Result { to_js(&self.inner.choose_task(now, background)) } #[wasm_bindgen(js_name = beginTurn)] pub fn begin_turn(&mut self, now: f64) { let mut dice = self.overrides.dice(); self.inner.begin_turn(now, &mut dice); } #[wasm_bindgen(js_name = chatTurn)] pub fn chat_turn(&self, kind: &str, now: f64) -> Result { if self.inner.state.pet.is_none() { return Err(JsError::new("an egg has no turns")); } Ok(ChatTurn(self.inner.chat_turn(kind, now, &self.seed))) } #[wasm_bindgen(js_name = commitLine)] pub fn commit_line(&mut self, body: &str, now: f64) { let mut dice = self.overrides.dice(); self.inner.commit_line(body, now, &self.seed, &mut dice); } /// Answers the open offer when the brain said nothing usable. #[wasm_bindgen(js_name = declineOffer)] pub fn decline_offer(&mut self, now: f64) { let mut dice = self.overrides.dice(); self.inner.answer_offer(false, now, &self.seed, &mut dice); } #[wasm_bindgen(js_name = diaryTurn)] pub fn diary_turn(&self, date: &str, now: f64, replace: bool) -> Option { self.inner .diary_turn(date, now, &self.seed, replace) .map(DiaryTurn) } /// Commits a diary page; false when the text was filtered (the day stays pending). #[wasm_bindgen(js_name = commitDiary)] pub fn commit_diary(&mut self, turn: &DiaryTurn, generated: &str) -> bool { match turn.0.finish(generated) { Some(page) => { self.inner.commit_page(page, turn.0.replace); true } None => false, } } /// Effects added since the last call, oldest first. #[wasm_bindgen(js_name = takeEffects)] pub fn take_effects(&mut self) -> Result { let seen = self.seen; let fresh: Vec<_> = self.inner.effects.iter().filter(|e| e.seq > seen).collect(); self.seen = self.inner.effect_seq; to_js(&fresh) } pub fn disturb(&mut self, kind: &str, now: f64) -> bool { let mut dice = self.overrides.dice(); self.inner.disturb(kind, now, &mut dice) } #[wasm_bindgen(js_name = resetDisturbances)] pub fn reset_disturbances(&mut self) { self.inner.reset_disturbances(); } /// Overrides wake rolls (browser checks); `null` restores `Math.random`. #[wasm_bindgen(js_name = setWakeRoll)] pub fn set_wake_roll(&mut self, roll: Option) { self.overrides.wake = roll; } /// Overrides every random draw (replay tests); `null` restores `Math.random`. #[wasm_bindgen(js_name = setRolls)] pub fn set_rolls(&mut self, rolls: Option) { self.overrides.rolls = rolls; } /// The whole engine as the host stores it, for cross-build checks. #[wasm_bindgen(js_name = engineJson)] pub fn engine_json(&self) -> Result { Ok(serde_json::to_string(&self.inner)?) } /// Debug: makes the pet `age` milliseconds old. #[wasm_bindgen(js_name = setAge)] pub fn set_age(&mut self, age: f64, now: f64) { if let Some(pet) = self.inner.state.pet.as_mut() { pet.hatched = now - age; pet.stage = body::stage_for(age).into(); self.inner.revision += 1; } } /// Debug: runs the body without the day log or chat lines. pub fn simulate(&mut self, from: f64, to: f64, quiet: bool) -> Result { let mut dice = self.overrides.dice(); let events = body::simulate_body(&mut self.inner.state, from, to, quiet, &mut || dice.roll()); self.inner.revision += 1; to_js(&events) } #[wasm_bindgen(js_name = alertPlan)] pub fn alert_plan(&self, now: f64) -> Result { let mut dice = self.overrides.dice(); to_js(&body::alert_plan(&self.inner.state, now, &mut || { dice.roll() })) } } /// One pet line in progress: request, feed each generated piece, read the body. #[wasm_bindgen] pub struct ChatTurn(turn::ChatTurn); #[wasm_bindgen] impl ChatTurn { #[wasm_bindgen(getter)] pub fn kind(&self) -> String { self.0.kind.clone() } pub fn request(&self) -> Result { to_js(&self.0.request()) } /// True when the line needs another generation. pub fn feed(&mut self, text: &str, reason: &str) -> bool { self.0.feed(text, reason) } pub fn text(&self) -> String { self.0.text().into() } pub fn body(&self) -> Option { self.0.body().map(String::from) } } #[wasm_bindgen] pub struct DiaryTurn(turn::DiaryTurn); #[wasm_bindgen] impl DiaryTurn { #[wasm_bindgen(getter)] pub fn date(&self) -> String { self.0.date.clone() } pub fn request(&self) -> Result { to_js(&self.0.request()) } }