chore: bootstrap nana-story M0

This commit is contained in:
Codex
2026-07-28 12:51:00 +08:00
commit 3dacc92423
71 changed files with 6840 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "nana-contracts"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
nana-domain.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
ts-rs.workspace = true
[lints]
workspace = true
+167
View File
@@ -0,0 +1,167 @@
use std::{
env, fs,
path::{Path, PathBuf},
};
use nana_domain::{
AcquisitionMode, ActionSuggestion, AppInfo, BeatKind, CharacterCard, CharacterJudgmentRule,
CharacterStyle, CheckDifficulty, CheckRecord, CheckResult, ClockState, DemoPackSummary,
HistoryNodeView, ItemAcquisition, ItemInstance, ItemMechanics, ItemPlacement, ItemSpec,
KnowledgeCertainty, KnowledgeRecord, Persona, PlayerItemView, PlayerKnowledgeView,
PlayerPromiseView, PlayerView, PlotEvent, PlotModule, PlotOutcome, PlotPressure, Promise,
PromiseStatus, PromiseWeight, RelationshipAdjustment, RelationshipAxes, RelationshipBand,
RelationshipDimension, RelationshipState, RelationshipView, ResourceBundle, ResourceHeader,
ResourceId, ResourceKind, ResourceRef, RuntimeState, SkillValue, StateDelta, StateOp, Story,
StoryBinding, StoryNode, TurnFailure, TurnFailureCode, TurnIntent, TurnRequest, TurnResult,
ValidationCode, ValidationIssue, ValidationReport, VisualDirective, WorldBook, WorldBookEntry,
};
use schemars::{JsonSchema, schema_for};
use serde::Serialize;
use sha2::{Digest, Sha256};
use ts_rs::TS;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let check = env::args().any(|argument| argument == "--check");
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let outputs = generated_outputs(&root)?;
if check {
let mut stale = Vec::new();
for (path, expected) in outputs {
if fs::read(&path).ok().as_deref() != Some(expected.as_slice()) {
stale.push(path);
}
}
if stale.is_empty() {
return Ok(());
}
for path in stale {
eprintln!("stale generated contract: {}", path.display());
}
return Err("generated contracts are stale".into());
}
for (path, bytes) in outputs {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, bytes)?;
}
Ok(())
}
fn generated_outputs(
root: &Path,
) -> Result<Vec<(PathBuf, Vec<u8>)>, Box<dyn std::error::Error>> {
let mut outputs = Vec::new();
let schema_dir = root.join("contracts/schema");
add_schema::<ResourceHeader>(&mut outputs, &schema_dir, "resource-header")?;
add_schema::<CharacterCard>(&mut outputs, &schema_dir, "character-card")?;
add_schema::<WorldBook>(&mut outputs, &schema_dir, "world-book")?;
add_schema::<Persona>(&mut outputs, &schema_dir, "persona")?;
add_schema::<PlotModule>(&mut outputs, &schema_dir, "plot-module")?;
add_schema::<ItemSpec>(&mut outputs, &schema_dir, "item-spec")?;
add_schema::<ResourceBundle>(&mut outputs, &schema_dir, "resource-bundle")?;
add_schema::<StoryNode>(&mut outputs, &schema_dir, "story-node")?;
add_schema::<RuntimeState>(&mut outputs, &schema_dir, "runtime-state")?;
add_schema::<PlayerView>(&mut outputs, &schema_dir, "player-view")?;
add_schema::<TurnRequest>(&mut outputs, &schema_dir, "turn-request")?;
add_schema::<TurnResult>(&mut outputs, &schema_dir, "turn-result")?;
add_schema::<TurnFailure>(&mut outputs, &schema_dir, "turn-failure")?;
add_schema::<AppInfo>(&mut outputs, &schema_dir, "app-info")?;
add_schema::<DemoPackSummary>(&mut outputs, &schema_dir, "demo-pack-summary")?;
let declarations = [
ResourceId::decl(),
ResourceKind::decl(),
ResourceRef::decl(),
ResourceHeader::decl(),
CharacterStyle::decl(),
CharacterJudgmentRule::decl(),
SkillValue::decl(),
CharacterCard::decl(),
WorldBookEntry::decl(),
WorldBook::decl(),
Persona::decl(),
PlotPressure::decl(),
PlotOutcome::decl(),
PlotEvent::decl(),
PlotModule::decl(),
ItemMechanics::decl(),
ItemSpec::decl(),
ResourceBundle::decl(),
RelationshipAxes::decl(),
RelationshipAdjustment::decl(),
RelationshipDimension::decl(),
RelationshipBand::decl(),
RelationshipView::decl(),
PromiseStatus::decl(),
PromiseWeight::decl(),
Promise::decl(),
RelationshipState::decl(),
KnowledgeCertainty::decl(),
KnowledgeRecord::decl(),
ItemPlacement::decl(),
AcquisitionMode::decl(),
ItemAcquisition::decl(),
ItemInstance::decl(),
CheckDifficulty::decl(),
CheckResult::decl(),
CheckRecord::decl(),
ClockState::decl(),
StoryBinding::decl(),
Story::decl(),
StateDelta::decl(),
StateOp::decl(),
StoryNode::decl(),
RuntimeState::decl(),
BeatKind::decl(),
VisualDirective::decl(),
PresentationBeat::decl(),
ActionSuggestion::decl(),
PlayerItemView::decl(),
PlayerKnowledgeView::decl(),
PlayerPromiseView::decl(),
HistoryNodeView::decl(),
PlayerView::decl(),
TurnIntent::decl(),
TurnRequest::decl(),
TurnResult::decl(),
TurnFailureCode::decl(),
TurnFailure::decl(),
AppInfo::decl(),
DemoPackSummary::decl(),
ValidationCode::decl(),
ValidationIssue::decl(),
ValidationReport::decl(),
]
.join("\n\n");
let ts = format!(
"// @generated by crates/nana-contracts; do not edit.\n\n{declarations}\n"
);
outputs.push((
root.join("contracts/ts/index.ts"),
ts.into_bytes(),
));
let domain_source = fs::read(root.join("crates/nana-domain/src/lib.rs"))?;
let source_hash = format!("{:x}\n", Sha256::digest(domain_source));
outputs.push((
root.join("contracts/.source.sha256"),
source_hash.into_bytes(),
));
Ok(outputs)
}
fn add_schema<T: JsonSchema + Serialize>(
outputs: &mut Vec<(PathBuf, Vec<u8>)>,
schema_dir: &Path,
name: &str,
) -> Result<(), serde_json::Error> {
let mut bytes = serde_json::to_vec_pretty(&schema_for!(T))?;
bytes.push(b'\n');
outputs.push((schema_dir.join(format!("{name}.schema.json")), bytes));
Ok(())
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "nana-domain"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
schemars.workspace = true
serde.workspace = true
sha2.workspace = true
thiserror.workspace = true
ts-rs.workspace = true
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nana-engine"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
nana-domain.workspace = true
thiserror.workspace = true
[lints]
workspace = true
+48
View File
@@ -0,0 +1,48 @@
use nana_domain::{RuntimeState, StateDelta};
use thiserror::Error;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ReduceError {
#[error("state delta is not implemented yet: {0} operation(s)")]
NotImplemented(usize),
}
/// M0 contract seam. M1 replaces this placeholder with the only authoritative
/// state mutation path in the application.
pub fn apply_delta(state: &RuntimeState, delta: &StateDelta) -> Result<RuntimeState, ReduceError> {
if delta.ops.is_empty() {
Ok(state.clone())
} else {
Err(ReduceError::NotImplemented(delta.ops.len()))
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use nana_domain::{RuntimeState, StateDelta};
use super::apply_delta;
#[test]
fn empty_delta_is_identity() {
let state = RuntimeState {
story_id: "story_demo".to_owned(),
current_node: "node_001".to_owned(),
current_branch: "branch_main".to_owned(),
world_flags: BTreeMap::new(),
relationships: BTreeMap::new(),
relationship_states: Vec::new(),
promises: Vec::new(),
knowledge: Vec::new(),
items: Vec::new(),
clocks: Vec::new(),
checks: Vec::new(),
};
assert_eq!(
apply_delta(&state, &StateDelta { ops: Vec::new() }),
Ok(state)
);
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "nana-runtime"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
nana-domain.workspace = true
openlapp.workspace = true
thiserror.workspace = true
[lints]
workspace = true
+53
View File
@@ -0,0 +1,53 @@
use std::collections::VecDeque;
use nana_domain::{TurnFailure, TurnFailureCode, TurnRequest, TurnResult};
use thiserror::Error;
pub const LAPP_BASELINE_COMMIT: &str = "5ba3c659e1536ec4bee16340faca603940a5cb17";
#[derive(Debug, Error)]
pub enum ProviderError {
#[error("no recorded response remains")]
FixtureExhausted,
#[error("LAPP profile could not be loaded: {0}")]
Profile(String),
}
pub trait TurnProvider {
fn complete_turn(&mut self, request: &TurnRequest) -> Result<TurnResult, ProviderError>;
}
#[derive(Debug)]
pub struct FakeProvider {
responses: VecDeque<TurnResult>,
}
impl FakeProvider {
#[must_use]
pub fn new(responses: impl IntoIterator<Item = TurnResult>) -> Self {
Self {
responses: responses.into_iter().collect(),
}
}
}
impl TurnProvider for FakeProvider {
fn complete_turn(&mut self, _request: &TurnRequest) -> Result<TurnResult, ProviderError> {
self.responses
.pop_front()
.ok_or(ProviderError::FixtureExhausted)
}
}
pub fn load_default_lapp_profile() -> Result<openlapp::Profile, ProviderError> {
openlapp::load_default_profile().map_err(|error| ProviderError::Profile(error.to_string()))
}
#[must_use]
pub fn provider_failure(message: impl Into<String>) -> TurnFailure {
TurnFailure {
code: TurnFailureCode::ProviderUnavailable,
message: message.into(),
retryable: true,
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "nana-store"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
nana-domain.workspace = true
thiserror.workspace = true
[lints]
workspace = true
+20
View File
@@ -0,0 +1,20 @@
use nana_domain::{RuntimeState, StoryNode};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum StoreError {
#[error("story not found: {0}")]
StoryNotFound(String),
#[error("storage backend is not initialized")]
NotInitialized,
}
pub trait StoryStore: Send + Sync {
fn append_node(&self, node: &StoryNode, state: &RuntimeState) -> Result<(), StoreError>;
fn load_state(
&self,
story_id: &str,
branch_id: &str,
) -> Result<RuntimeState, StoreError>;
}