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
60 changes: 60 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: web

# Builds the config editor and publishes it to Pages. It is a static bundle by
# design: the page can never be the reason someone's README breaks, because
# there is nothing running for it to break. Their config lives in their repo and
# the workflow runs there.
on:
push:
branches: [main]
paths: ["web/**", "crates/awan-core/**", ".github/workflows/web.yml"]
pull_request:
paths: ["web/**", "crates/awan-core/**"]
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
with:
workspaces: web/crate
- uses: jetli/wasm-pack-action@v0.4.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: web/package-lock.json

- run: npm ci
working-directory: web
- run: npm run wasm
working-directory: web
- run: npm run build
working-directory: web

- uses: actions/upload-pages-artifact@v3
if: github.ref == 'refs/heads/main'
with:
path: web/dist

deploy:
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- id: deploy
uses: actions/deploy-pages@v4
2 changes: 1 addition & 1 deletion awan.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"handle": "codewithwan/awan",
"username": "codewithwan/awan",
"name": "awan",
"role": "a tiny living character for your terminal",
"stack": "Rust",
Expand Down
10 changes: 7 additions & 3 deletions profile/src/icons.rs → crates/awan-core/src/icons.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
//! Tiny 8×8 pixel icons drawn beside profile lines. Each `u8` is a row; bit
//! `1 << col` is the pixel at column `col` (0 = left), matching the font byte
//! order so they rasterise with the same routine.
//! Tiny 8×8 pixel icons a renderer can draw beside a line. Each `u8` is a row;
//! bit `1 << col` is the pixel at column `col` (0 = left), matching font8x8's
//! byte order so they rasterise with the same routine.
//!
//! They live in the engine rather than next to one renderer because there are
//! two now — the GIF encoder and the browser preview — and a preview that
//! draws its own idea of these icons is a preview that lies.

pub struct Icon(pub [u8; 8]);

Expand Down
2 changes: 2 additions & 0 deletions crates/awan-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ mod statusline;

/// Layout of the `stats` act, for renderers that print the numbers onto the
/// bento cards the character sets out.
pub mod icons;

pub mod stats {
pub use crate::scene::stats::{PANEL, SLOTS, chars_at, panel_at, typing};
}
Expand Down
95 changes: 11 additions & 84 deletions crates/awan-core/src/spec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;

mod validate;

use serde::Deserialize;

/// Supported spec revision; declared by every character so old specs never
Expand Down Expand Up @@ -109,92 +111,17 @@ impl fmt::Display for SpecError {

impl std::error::Error for SpecError {}

/// Load and validate a character spec from a TOML file.
pub fn load(path: &Path) -> Result<CharacterSpec, SpecError> {
let raw = std::fs::read_to_string(path).map_err(SpecError::Io)?;
let spec: CharacterSpec = toml::from_str(&raw).map_err(SpecError::Parse)?;
/// Parse and validate a character spec from TOML text.
///
/// Split out from [`load`] because the browser has the text but no filesystem,
/// and a preview that can't restyle itself can't show what a character does.
pub fn parse(raw: &str) -> Result<CharacterSpec, SpecError> {
let spec: CharacterSpec = toml::from_str(raw).map_err(SpecError::Parse)?;
spec.validate()?;
Ok(spec)
}

impl CharacterSpec {
pub fn validate(&self) -> Result<(), SpecError> {
let fail = |msg: String| Err(SpecError::Invalid(msg));

if self.spec_version != SPEC_VERSION {
return fail(format!(
"spec_version {} is not supported (engine supports {SPEC_VERSION})",
self.spec_version
));
}
let s = &self.sprite;
for (set, name, len) in [
(&s.rows, "rows", SPEC_H),
(&s.sit_rows, "sit_rows", SPEC_H),
(&s.leg_frames, "leg_frames", 4),
] {
if set.len() != len {
return fail(format!("sprite.{name} must have exactly {len} rows"));
}
for (i, row) in set.iter().enumerate() {
if row.chars().count() != SPEC_W {
return fail(format!("sprite.{name}[{i}] must be {SPEC_W} pixels wide"));
}
}
}
if let Some(row) = &s.shimmer_row {
if row.chars().count() != SPEC_W {
return fail(format!("sprite.shimmer_row must be {SPEC_W} pixels wide"));
}
}
// Face rows shift one row down when sitting, so they can't be last.
for (idx, name, max) in [
(s.eye_row, "eye_row", SPEC_H - 1),
(s.mouth_row, "mouth_row", SPEC_H - 1),
(s.legs_row, "legs_row", SPEC_H),
] {
if idx >= max {
return fail(format!("sprite.{name} {idx} out of range (max {max})"));
}
}
if !s.rows[s.eye_row].contains('@') {
return fail(format!(
"sprite.rows[{}] (eye_row) must contain '@' eyes",
s.eye_row
));
}
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;

fn reference_spec_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../characters/awan.toml")
}

#[test]
fn reference_character_loads_and_validates() {
let spec = load(&reference_spec_path()).expect("characters/awan.toml must be valid");
assert_eq!(spec.character.name, "Awan");
assert_eq!(spec.sprite.rows.len(), 6);
assert!(spec.character.palette.contains_key("body"));
}

#[test]
fn version_mismatch_is_rejected() {
let mut spec = load(&reference_spec_path()).unwrap();
spec.spec_version = 999;
assert!(matches!(spec.validate(), Err(SpecError::Invalid(_))));
}

#[test]
fn eyeless_eye_row_is_rejected() {
let mut spec = load(&reference_spec_path()).unwrap();
spec.sprite.rows[spec.sprite.eye_row] = "##########".into();
assert!(matches!(spec.validate(), Err(SpecError::Invalid(_))));
}
/// Load and validate a character spec from a TOML file.
pub fn load(path: &Path) -> Result<CharacterSpec, SpecError> {
parse(&std::fs::read_to_string(path).map_err(SpecError::Io)?)
}
90 changes: 90 additions & 0 deletions crates/awan-core/src/spec/validate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//! What makes a spec usable, as opposed to merely parseable.
//!
//! Separate from the spec's shape because they're two jobs: serde says whether
//! the TOML is a document, this says whether the document is a character. The
//! errors are the whole point — someone writing pixel art in a text file gets
//! the line they got wrong, not "invalid spec".

use super::*;

impl CharacterSpec {
pub fn validate(&self) -> Result<(), SpecError> {
let fail = |msg: String| Err(SpecError::Invalid(msg));

if self.spec_version != SPEC_VERSION {
return fail(format!(
"spec_version {} is not supported (engine supports {SPEC_VERSION})",
self.spec_version
));
}
let s = &self.sprite;
for (set, name, len) in [
(&s.rows, "rows", SPEC_H),
(&s.sit_rows, "sit_rows", SPEC_H),
(&s.leg_frames, "leg_frames", 4),
] {
if set.len() != len {
return fail(format!("sprite.{name} must have exactly {len} rows"));
}
for (i, row) in set.iter().enumerate() {
if row.chars().count() != SPEC_W {
return fail(format!("sprite.{name}[{i}] must be {SPEC_W} pixels wide"));
}
}
}
if let Some(row) = &s.shimmer_row {
if row.chars().count() != SPEC_W {
return fail(format!("sprite.shimmer_row must be {SPEC_W} pixels wide"));
}
}
// Face rows shift one row down when sitting, so they can't be last.
for (idx, name, max) in [
(s.eye_row, "eye_row", SPEC_H - 1),
(s.mouth_row, "mouth_row", SPEC_H - 1),
(s.legs_row, "legs_row", SPEC_H),
] {
if idx >= max {
return fail(format!("sprite.{name} {idx} out of range (max {max})"));
}
}
if !s.rows[s.eye_row].contains('@') {
return fail(format!(
"sprite.rows[{}] (eye_row) must contain '@' eyes",
s.eye_row
));
}
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;

fn reference_spec_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../characters/awan.toml")
}

#[test]
fn reference_character_loads_and_validates() {
let spec = load(&reference_spec_path()).expect("characters/awan.toml must be valid");
assert_eq!(spec.character.name, "Awan");
assert_eq!(spec.sprite.rows.len(), 6);
assert!(spec.character.palette.contains_key("body"));
}

#[test]
fn version_mismatch_is_rejected() {
let mut spec = load(&reference_spec_path()).unwrap();
spec.spec_version = 999;
assert!(matches!(spec.validate(), Err(SpecError::Invalid(_))));
}

#[test]
fn eyeless_eye_row_is_rejected() {
let mut spec = load(&reference_spec_path()).unwrap();
spec.sprite.rows[spec.sprite.eye_row] = "##########".into();
assert!(matches!(spec.validate(), Err(SpecError::Invalid(_))));
}
}
4 changes: 2 additions & 2 deletions docs/PROFILE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ core personality-layer CLI is untouched.

Identity fields, a streak, a song + lyrics, an `output` path, and a `scenes`
array of `{ act, say }` beats. `say` supports `{name} {role} {location} {stack}
{streak} {handle}`; the `sing` beat plays `lyrics` instead. See the full,
{streak} {username}`; the `sing` beat plays `lyrics` instead. See the full,
copy-ready file in [`profile/sample/awan.json`](../profile/sample/awan.json):

```jsonc
{
"handle": "codewithwan",
"username": "codewithwan",
"name": "Muhammad Ridwan",
"role": "fullstack engineer",
"stack": "Rust, Go & TypeScript",
Expand Down
4 changes: 2 additions & 2 deletions profile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ its own — no Ctrl+C.

```jsonc
{
"handle": "codewithwan",
"username": "codewithwan",
"character": "", // path to a character TOML; empty = the buddy
"name": "Muhammad Ridwan",
"role": "fullstack engineer",
Expand Down Expand Up @@ -112,7 +112,7 @@ its own — no Ctrl+C.
| `sleep` | yawns, dozes (`zzz`), wakes up |
| `dance` | a little dance |

- **`say`** is the caption; `{name} {role} {location} {stack} {streak} {handle}`
- **`say`** is the caption; `{name} {role} {location} {stack} {streak} {username}`
are filled in, plus `{contrib_year}` and `{contrib_recent}`. The `sing` beat
needs no `say` — it plays your `lyrics`.
- Omit `scenes` entirely for a sensible default story.
Expand Down
2 changes: 1 addition & 1 deletion profile/sample/awan.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"handle": "codewithwan",
"username": "codewithwan",
"name": "Muhammad Ridwan",
"role": "fullstack engineer, crafting smooth UX",
"location": "Indonesia",
Expand Down
2 changes: 1 addition & 1 deletion profile/sample/project.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"handle": "codewithwan/awan",
"username": "codewithwan/awan",
"name": "awan",
"role": "a tiny living character for your terminal",
"stack": "Rust",
Expand Down
2 changes: 1 addition & 1 deletion profile/src/gif.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use image::codecs::gif::{GifEncoder, Repeat};
use image::{Delay, Frame, Rgba, RgbaImage};

use crate::draw::{draw_bits, draw_text, fill};
use crate::icons;
use crate::script::{Line, Profile};
use crate::wall::wall;
use awan_core::icons;

/// Pixels per canvas cell (32 cols × this ≈ 1050 px wide — safe in VHS too).
pub const CELL_W: u32 = 33;
Expand Down
3 changes: 1 addition & 2 deletions profile/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ use awan_core::{Character, Reel, Size};

mod draw;
mod gif;
mod icons;
mod script;
mod story;
mod wall;
Expand Down Expand Up @@ -70,7 +69,7 @@ fn load(path: &str) -> Profile {
/// Build a profile from command-line flags.
fn from_flags(args: &[String]) -> Profile {
Profile {
handle: args.get(1).cloned().unwrap_or_default(),
username: args.get(1).cloned().unwrap_or_default(),
character: flag(args, "-c").unwrap_or_default(),
name: flag(args, "--name").unwrap_or_default(),
role: flag(args, "--role").unwrap_or_default(),
Expand Down
Loading
Loading