diff --git a/cyo/items.cyo b/cyo/items.cyo index 1dec750..9f7a76d 100644 --- a/cyo/items.cyo +++ b/cyo/items.cyo @@ -1,4 +1,61 @@ -[Duct Tape] -d:The tape that does it all. +DuctTape: + str="Duct Tape" + status=[.Full, .Empty] + location=Warehouse -[Flash Light] +FlashLight: + str="Flash Light" + status=[.Bright, .Dead, .Broken] + _onExamine: + if $self.status = .Bright: + A handy tool for seeing your way out. + else if $self.status = .Dead: + A handy tool for seeing your way out, unfortunately its out of batteries. + else: + A handy tool for seeing your way out, unfortunately its busted. + + _onUse: + if $context.args.contains(Batteries) and $self.status = .Dead: + $self.status = .Bright + $player.inventory.remove(Batteries) + + You screw the back on tight, and the light comes on! + _onFix: + if $context.args.contains(DuctTape) and $self.status = .Broken: + $self.status = .Dead + $DuctTape.status = .Empty + + Nothing duct tape cant fix! The flashlight just needs some power. + +Batteries: + _onExamine: + Energized little cylinders, good for something... + +# Hammer: + +# [Hammer] +# desc= Good for breaking things +# status=Solid +# rel=Flash Light=>Broken + +# [Key1] +# name= Key +# desc= A mysterious key + +# [Key2] +# name= Key +# desc= A different key, not to be confused with the other + +# [Locked Chest] +# decs= A large chest with two key holes +# status=Locked,Closed,Opened +# *Open= +# &Locked::Its locked +# &Opened::Its already open +# &:s.Opened:The large chest swings open. +# *Close= +# &Locked::[Its locked, and therefore closed. You arent strong enough to break it with your hands.You cant break it open, but maybe with something tough enough...] +# &Closed::Its already closed!! +# &Opened:s.Closed:The chest closes with a clunk. +# *Unlock= +# & diff --git a/cyo_spec.md b/cyo_spec.md new file mode 100644 index 0000000..03d5f37 --- /dev/null +++ b/cyo_spec.md @@ -0,0 +1,161 @@ +# Mindset + +In the world of text adventures, at any given moment, a player: +- Resides in a specific location +- Can issue any arbitrary command + - Said command should never cause an error +- Posseses an inventory with any number of objects +- + +All entities in a text adventure world are typically broken into two categories: scenes and objects. +But, I believe there is good reason to abstract all entities into just objects. + +Then, objects are duck-typed into specific things by way of their available methods. +Methods include but are not limited to: +- `_onEnter`, used for scenes, to read out text of the location +- `_onExit`, used for scenes, to direct the player to the next destination +- `_onAcquire`, used for objects that are meant to be kept +- `_onInpect`, used for any object when the player wishes to know more + +A graph markdown was deemed insufficient for cyano, as my stories are not going to fit cleanly along +some API. What I really will need to be able to do is code it, as any good engine of any kind. + +Goals for the language: + +## 1. Reading as First Class +A story is only worth what its words convey, and you only have a good story if you can read it. + +Imagine a file whose contents read: +``` +func goToWindow: +print("You approach the window and its clear you could survive the jump"); +input = get_input() +if input == "jump": + print("You fall gracefully to the ground") +else if + ... +``` + +It honestly would be just as well to scrap this project and write a one-off in python. +The purpose of writing an entire engine would be to pull away all of the unnecessary bits and bytes +of sequencing, IO, object relations and requirements and let the writer focus on the actual writing. + +Instead picture a file like this: +``` +_onEnter: + You approach the window and its clear you could survive the jump + +_onJump: + You fall gracefully to the ground +``` + +This is still the outset, so more syntax may be required, but the goal would be to make everything +else get out of the way, and reading your story takes the front seat. + +## 2a. Arbitrary Functionality +A story can take us anywhere. So why would the engine that powers it only allow objects to have one of up to +a maximum of three conditions (Opened, Closed, Locked). + +In reality, a chest could be Opened, Broken, Dusty, Sentient and who knows what else. The goal would be to allow +the writer to have utmost freedom in writing the story they want to tell. + +## 2b. Safe Operation +However, we dont want a dynamic story to have dead ends, missing links, or endless loops. When a chest depends on +a key that doesnt exist? Thats no good. We want as many conditions as possible to be checked as soon as possible +in the writing process to help make the greatest story that has yet to be experienced. + + + + + + + + + +# Cyo Spec + +## Summary + +Cyano's underlying framework is using callbacks. You can think of it as defining a series of triggers and effects. +Essentially, nothing happens without the user doing something, so we dont need the cyano engine to "run" code. +Instead, it should read the user input, figure out what they mean, then lookup the associated trigger that the user is tripping. + +Triggers could be anything: +- Entering a room +- Combining things in their inventory +- Putting a monkey idol on a pillar +- Reaching zero health + +So, with these two concepts: entities and callbacks (triggers), we can create any story with any sort of form. + +Want to make it RPG? Create a player entity with stats, and create triggers like `_onKillMob`, `_onLevelUp`. +Traditional text escape adventure? Chest entity with `_onUnlock`, `_onOpen`. + +## Nitty Gritty + +### Entities +Defining Entities: +- A file can define multiple entities only if these definitions are qualified. +- A file cannot have unqualified and qualified definitions. +- A file can only define one set of unqualified definitions and no others. +- Entity identifiers are case-insensitive, and can only contain letters, numbers, and underscores. + +Qualified Definition: +```cyo +scenes.cyo + +_onEnter_LivingRoom: +_onLeave_LivingRoom: +_onExplode_LivingRoom: +``` + +Unqualified Definition: +```cyo +living_room.cyo + +_onEnter: +_onLeave: +_onExplode: +``` + +Both would create internally a LivingRoom entity. +In the case of an unqualified definition, the basename of the file is used as the entities identifier. Underscores in +the filename will be removed. If an underscore is desired in the identifier, the filename would need two: +`living__room.cyo` + +### Global State +Global state is accessible within any callback with said object properties TBD but would look like: +```cyo + +_onRead: + if $player.wearingPendant: + The book's text comes to life! + + else: + The book's dull pages seem ready to fall apart with age. +``` + +Attributes that would be handy for the global state to hold: +- Inventory +- Current location +- Recent action(s) +- Arbitrary stats (author defined) + +Should these all reside in the `$player` object? This will probably evolve as need arises. + +### Entity State +You want to open a door, but only if the pressure plate in the room is weighed down with something. Entities need to be +able to define and track arbitrary internal state. This might look something like: +```cyo +PressurePlate: + pressed=false + +_onInspect_PressurePlate: + if $pressed: + "Its depressed, now what?" + else if $player.: + "Its barely noticable on the dusty stone floor, but you can just make out the lip of the plate. Something heavy could depress it." + +``` + +### Callback Rules diff --git a/src/parser/cyo_parser.zig b/src/parser/cyo_parser.zig index dbd0407..1ab2c35 100644 --- a/src/parser/cyo_parser.zig +++ b/src/parser/cyo_parser.zig @@ -1,7 +1,29 @@ const std = @import("std"); const CyoContent = @import("../cyo/cyo.zig").content; -const DEFAULT_CYO_SOURCE_PATH = "cyo"; +const DEFAULT_CYO_SOURCE_PATH = "./cyo"; + +const Token = struct { line: u32, pos: u32, lexeme: Lexeme, contents: []const u8 }; + +const Lexeme = enum { + Lt, // < + Gt, // > + LeftParen, // ( + RightParen, // ) + LeftBracket, // [ + RightBracket, // ] + Text, // Foo bar blah. Another. + Dollar, // $ + Period, // . + Colon, // : + Equals, // = + Hashtag, // # + Underscore, // _ + Newline, // \n + Tab, // \t + Space, // ' ' + Eof, // +}; pub const CyoError = error{ BadSource, BadIter }; @@ -38,21 +60,22 @@ pub const CyoParser = struct { var files_contents = std.StringHashMap([]const u8).init(allocator); try walkDirs(allocator, cyo_dir, 0, &files_contents); - var iter = files_contents.keyIterator(); + var files_tokens = try lexCyoFiles(allocator, files_contents); + printFilesTokens(files_tokens); + + var iter = files_tokens.keyIterator(); while (iter.next()) |key| { - const content = files_contents.get(key.*); - if (content) |c| { - std.debug.print("Got key: {s}\nContent:{s}\n\n", .{ key.*, c }); - } else { - std.debug.print("Got key: {s}\nContent empty\n\n", .{key.*}); + const tokens = files_tokens.get(key.*); + if (tokens) |t| { + t.deinit(); } + allocator.free(key.*); } - - // var path_buf: [128]u8 = undefined; - + files_tokens.deinit(); // const cyo_dir_path = cyo_dir.realpath(pathname: []const u8, out_buffer: []u8) // 2. process files + // 2a. lexical - validate file syntax // 2b. syntactic parsing // 2c. semantic - create objects and scenes @@ -61,6 +84,7 @@ pub const CyoParser = struct { return CyoContent{ .allocator = allocator, .files_contents = files_contents }; } + // Recursively walks through directory to get all .cyo files fn walkDirs(allocator: std.mem.Allocator, cyo_dir: std.fs.Dir, depth: u8, files_contents: *std.StringHashMap([]const u8)) !void { var cyo_iter = cyo_dir.iterate(); while (cyo_iter.next() catch |err| { @@ -69,11 +93,15 @@ pub const CyoParser = struct { }) |cyo_entry| { switch (cyo_entry.kind) { .file => { - // std.fs.cyo_entry.name; + // only grab `.cyo` files + if (cyo_entry.name.len <= 3 or !std.mem.eql(u8, ".cyo", cyo_entry.name[cyo_entry.name.len - 4 .. cyo_entry.name.len])) { + continue; + } + for (0..depth) |_| { std.debug.print("\t", .{}); } - std.debug.print("- File: {s}\n", .{cyo_entry.name}); + // std.debug.print("- File: {s}\n", .{cyo_entry.name}); const file_path = try cyo_dir.realpathAlloc(allocator, cyo_entry.name); var cyo_file = try std.fs.openFileAbsolute(file_path, .{ .mode = .read_only }); @@ -84,7 +112,7 @@ pub const CyoParser = struct { for (0..depth) |_| { std.debug.print("\t", .{}); } - std.debug.print("Dir: {s}\n", .{cyo_entry.name}); + // std.debug.print("Dir: {s}\n", .{cyo_entry.name}); const dir_path = try cyo_dir.realpathAlloc(allocator, cyo_entry.name); defer allocator.free(dir_path); @@ -92,18 +120,220 @@ pub const CyoParser = struct { try walkDirs(allocator, new_cyo_dir, depth + 1, files_contents); }, else => { - std.debug.print("ignoring other types...", .{}); + // std.debug.print("ignoring other types...", .{}); }, } } } + + fn printFilesContents(files_contents: std.StringHashMap([]const u8)) void { + var iter = files_contents.keyIterator(); + while (iter.next()) |key| { + const content = files_contents.get(key.*); + if (content) |c| { + std.debug.print("Got key: {s}\nContent:{s}\n\n", .{ key.*, c }); + } else { + std.debug.print("Got key: {s}\nContent empty\n\n", .{key.*}); + } + } + } + + fn lexCyoFiles(allocator: std.mem.Allocator, files_contents: std.StringHashMap([]const u8)) !std.StringHashMap(std.ArrayList(Token)) { + var files_tokens = std.StringHashMap(std.ArrayList(Token)).init(allocator); + errdefer { + var iter = files_tokens.keyIterator(); + while (iter.next()) |key| { + const tokens = files_tokens.get(key.*); + if (tokens) |t| { + t.deinit(); + } + allocator.free(key.*); + } + files_tokens.deinit(); + } + + var iter = files_contents.keyIterator(); + while (iter.next()) |key| { + const content = files_contents.get(key.*); + if (content == null) { + continue; + } + + var tokens = std.ArrayList(Token).init(allocator); + const c = content.?; + var i: u32 = 0; + var line: u32 = 1; + var col: u32 = 1; + while (i < c.len) { + const lexeme = charToLexeme(c[i]); + switch (lexeme) { + // whitespace + .Space, .Tab => { + const char_repeats = greedyCapture(c, i); + for (0..char_repeats) |_| { + try tokens.append(.{ .line = line, .pos = col, .lexeme = lexeme, .contents = c[i .. i + char_repeats] }); + col += 1; + } + i += char_repeats; + }, + .Newline => { + try tokens.append(.{ .line = line, .pos = col, .lexeme = lexeme, .contents = c[i .. i + 1] }); + line += 1; + col = 1; + i += 1; + }, + // symbols + .Gt, + .Lt, + .LeftParen, + .RightParen, + .LeftBracket, + .RightBracket, + .Dollar, + .Period, + .Equals, + .Colon, + .Underscore, + => { + try tokens.append(.{ .line = line, .pos = col, .lexeme = lexeme, .contents = c[i .. i + 1] }); + // check length of content is one + std.debug.assert(tokens.items[tokens.items.len - 1].contents.len == 1); + + col += 1; + i += 1; + }, + // text + .Text => { + const text_length = captureText(c, i); + try tokens.append(.{ .line = line, .pos = col, .lexeme = lexeme, .contents = c[i .. i + text_length] }); + + col += text_length; + i += text_length; + }, + .Hashtag => { + const to_end_of_line = captureLine(c, i); + // TODO for testing, remove as we dont need to save comments + try tokens.append(.{ .line = line, .pos = col, .lexeme = lexeme, .contents = c[i .. i + to_end_of_line] }); + + col += to_end_of_line; + i += to_end_of_line; + }, + .Eof => unreachable, + } + } + // Add eof token + try tokens.append(.{ .line = line, .pos = col, .lexeme = Lexeme.Eof, .contents = "" }); + + // Add tokens to hashmap + const key_copy = try allocator.alloc(u8, key.len); + std.mem.copyForwards(u8, key_copy, key.*); + try files_tokens.put(key_copy, tokens); + } + return files_tokens; + } + + fn printFilesTokens(files_tokens: std.StringHashMap(std.ArrayList(Token))) void { + var iter = files_tokens.keyIterator(); + while (iter.next()) |key| { + std.debug.print("File: {s}", .{key.*}); + const tokens = files_tokens.get(key.*); + if (tokens) |ts| { + for (ts.items) |token| { + std.debug.print("\tGot Token: {s}\tCol{d}:L{d}\t{s}\n", .{ @tagName(token.lexeme), token.pos, token.line, token.contents }); + } + } + } + } + + fn charToLexeme(char: u8) Lexeme { + switch (char) { + ' ' => { + return .Space; + }, + '\t' => { + return .Tab; + }, + '\n' => { + return .Newline; + }, + '<' => { + return .Lt; + }, + '>' => { + return .Gt; + }, + '(' => { + return .LeftParen; + }, + ')' => { + return .RightParen; + }, + '[' => { + return .LeftBracket; + }, + ']' => { + return .RightBracket; + }, + '$' => { + return .Dollar; + }, + '.' => { + return .Period; + }, + '=' => { + return .Equals; + }, + ':' => { + return .Colon; + }, + '_' => { + return .Underscore; + }, + '#' => { + return .Hashtag; + }, + else => return .Text, + } + } + + // given a char, it returns the number of bytes that char repeats forward starting at `i` + fn greedyCapture(seq: []const u8, i: u32) u32 { + const cap_char = seq[i]; + var j = i; + while (j < seq.len and seq[j] == cap_char) { + j += 1; + } + return j - i; + } + + fn captureText(seq: []const u8, i: u32) u32 { + var j = i; + while (j < seq.len and charToLexeme(seq[j]) == .Text) { + j += 1; + } + return j - i; + } + + fn captureLine(seq: []const u8, i: u32) u32 { + var j = i; + while (j < seq.len) { + const lex = charToLexeme(seq[j]); + if (lex == .Newline or lex == .Eof) { + break; + } + j += 1; + } + return j - i; + } }; test "parse test" { + // TODO, programatically create a test directory instead of relying on the test directory to have the right contents const cyo_test_dir_path = try std.fs.cwd().realpathAlloc(std.testing.allocator, "./test/cyo_test_dir"); defer std.testing.allocator.free(cyo_test_dir_path); var cyo_parser = try CyoParser.init(std.testing.allocator, cyo_test_dir_path); defer cyo_parser.deinit(); - try std.testing.expectEqual(6, cyo_parser.cyo_content.files_contents.count()); + // Verify reading in of correct files + try std.testing.expectEqual(7, cyo_parser.cyo_content.files_contents.count()); } diff --git a/test/cyo_test_dir/actions.cyo b/test/cyo_test_dir/actions.cyo new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/test/cyo_test_dir/actions.cyo @@ -0,0 +1 @@ + diff --git a/test/cyo_test_dir/items.cyo b/test/cyo_test_dir/items.cyo index 1dec750..a0a9d9c 100644 --- a/test/cyo_test_dir/items.cyo +++ b/test/cyo_test_dir/items.cyo @@ -1,4 +1,32 @@ -[Duct Tape] -d:The tape that does it all. +DuctTape: + str="Duct Tape" + status=[.Full, .Empty] + location=Warehouse -[Flash Light] +FlashLight: + str="Flash Light" + status=[.Bright, .Dead, .Broken] + _onExamine: + if $self.status = .Bright: + A handy tool for seeing your way out. + else if $self.status = .Dead: + A handy tool for seeing your way out, unfortunately its out of batteries. + else: + A handy tool for seeing your way out, unfortunately its busted. + + _onUse: + if $context.args.contains(Batteries) and $self.status = .Dead: + $self.status = .Bright + $player.inventory.remove(Batteries) + + You screw the back on tight, and the light comes on! + _onFix: + if $context.args.contains(DuctTape) and $self.status = .Broken: + $self.status = .Dead + $DuctTape.status = .Empty + + Nothing duct tape cant fix! The flashlight just needs some power. + +Batteries: + _onExamine: + Energized little cylinders, good for something... diff --git a/test/cyo_test_dir/main.cyo b/test/cyo_test_dir/main.cyo index 9e95962..b94f982 100644 --- a/test/cyo_test_dir/main.cyo +++ b/test/cyo_test_dir/main.cyo @@ -1,17 +1,13 @@ -[Warehouse] +Warehouse: + _onEnter: + Your vision blurs as you fumble for the tablet. The glow of your fingers is nearly gone. The salty tablet is the last + sensation before everything goes dark. + + + You awaken. How long has it been? Thanks to your dull glow, you can read the clock on the wall. 8:36. Only a few hours. + Thank God you brought one with you. But its not a cure, just a band-aid. Time is ticking. You've got 24 hours + to breathe, better get back before then. -Your vision blurs as you fumble for the tablet. The glow of your fingers is nearly gone. The salty tablet is the last -sensation before everything goes dark. - ---- - -You awaken. How long has it been? Thanks to your dull glow, you can read the clock on the wall. 8:36. Only a few hours. -Thank God you brought one with you. But its not a cure, just a band-aid. Time is ticking. You've got 24 hours -to breathe, better get back before then. - -[Warehouse.d] -It might have been an Amazon warehouse. Everything seems sacked. Empty shelves twist in some corporate labrynth before you. -Would have been nice to have something to show for this trip. But information isn't useless. - -[Warehouse.i] -Duct Tape + _onExamine: + It might have been an Amazon warehouse. Everything seems sacked. Empty shelves twist in some corporate labrynth before you. + Would have been nice to have something to show for this trip. But information isn't useless. diff --git a/test/cyo_test_dir/scenes/bathroom.cyo b/test/cyo_test_dir/scenes/bathroom.cyo index 14fd67e..cc3e90e 100644 --- a/test/cyo_test_dir/scenes/bathroom.cyo +++ b/test/cyo_test_dir/scenes/bathroom.cyo @@ -1 +1,25 @@ -[Bathroom] +Bathroom: + washed=false + + _onEnter: + Its a normal bathroom. + if not $washed: + Should probably wash up. + + _onLook: + Bathroom complete with sink, faucet, the whole lot. + + _onWash: + $washed=true + Nice cold water. + + _beforeOnWash: + if $washed: + You dont need to wash again. + _block + + _afterOnWash: + if not $previous.bathroom.washed: + You feel refreshed. + + Time to get to work. diff --git a/test/cyo_test_dir/scenes/bathroom.foo b/test/cyo_test_dir/scenes/bathroom.foo new file mode 100644 index 0000000..14fd67e --- /dev/null +++ b/test/cyo_test_dir/scenes/bathroom.foo @@ -0,0 +1 @@ +[Bathroom] diff --git a/test/cyo_test_dir/scenes/planet/ship/crew.cyo b/test/cyo_test_dir/scenes/planet/ship/empty.cyo similarity index 100% rename from test/cyo_test_dir/scenes/planet/ship/crew.cyo rename to test/cyo_test_dir/scenes/planet/ship/empty.cyo