From 759069ad0cc5d8ced80ffefe5780e07799f6448d Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 13:35:32 -0700 Subject: [PATCH 01/12] Dispatch a list message on the message in its head ['MOVE TO' player] -> vase now runs the vase's 'MOVE TO' method, with the rest of the list along as arguments, read with "head tail message". The send site needed nothing: OP_SEND already hands the recipient whatever value it was given, and a list rode through it intact -- only to fail messageConversion() in dispatch and land in the default method. So the rule lives in Object::dispatch, where pass and every send in the interpreter reach it too, rather than in the operator, where it would have had to be written three times. Nothing that dispatched before dispatches differently: head() is undefined for every value that is not a pair, so the new conversion is only ever tried where the old one already failed. A list whose head is no kind of message still falls to the default method, as it did. "message" stays bound to the whole list, so a method can read its arguments and still forward them with "message --> parent". No new tokens, no new enumerators, no change to the .acx layout -- the golden files come out byte for byte identical. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- src/Object.cc | 14 +++++++-- src/TestObject.cc | 72 +++++++++++++++++++++++++++++++++++++++++++++++ src/TestObject.hh | 1 + 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/Object.cc b/src/Object.cc index c62cc8e..a54a1d1 100644 --- a/src/Object.cc +++ b/src/Object.cc @@ -74,15 +74,25 @@ namespace archetype { } Value Object::dispatch() { - Value defined_message = Universe::instance().currentContext().messageValue->messageConversion(); + const Value& sent = Universe::instance().currentContext().messageValue; + Value defined_message = sent->messageConversion(); + if (not defined_message->isDefined()) { + // A list dispatches on its head, so that ['MOVE TO' player] finds the + // 'MOVE TO' method and the rest of the list rides along as arguments. + // Nothing that dispatched before dispatches differently now: every + // value that is not a pair has an undefined head. + defined_message = sent->head()->messageConversion(); + } Value absence = make_unique(); Value result = make_unique(); int message_id = -1; if (defined_message->isDefined()) { if (Debug) { Value target = make_unique(id()); + // The whole message, not just the head it dispatched on: a trace + // that hid the arguments would hide what one wants to trace. Universe::instance().output()->put(format("dispatching {} to {}", - defined_message, target)); + sent, target)); Universe::instance().output()->endLine(); } message_id = defined_message->getMessage(); diff --git a/src/TestObject.cc b/src/TestObject.cc index febe15d..7284982 100644 --- a/src/TestObject.cc +++ b/src/TestObject.cc @@ -166,6 +166,77 @@ namespace archetype { ARCHETYPE_TEST_EQUAL(actual2, expected2); } + void TestObject::testListMessages_() { + ObjectPtr vase = Universe::instance().defineNewObject(); + Universe::instance().assignObjectIdentifier(vase, "vase"); + int move_to_id = Universe::instance().Messages.index("MOVE TO"); + // The parentheses around "head tail message" are not decoration: head and + // tail bind more loosely than "=", so without them the comparison would + // happen first, against the message itself + vase->setMethod(move_to_id, + make_stmt_from_str("{ if (head tail message) = UNDEFINED then {\n" + " write \"the vase stays put\"\n" + " } else {\n" + " write \"the vase moves to \", head tail message\n" + " } }\n")); + // The default method sees the message the sender wrote, list and all + vase->setMethod(DefaultMethod, + make_stmt_from_str("write \"the vase ignores \", head message")); + + Statement stmt1 = make_stmt_from_str("['MOVE TO' \"the attic\"] -> vase"); + Capture capture1; + stmt1->execute(); + string expected1 = "the vase moves to the attic\n"; + string actual1 = capture1.getCapture(); + ARCHETYPE_TEST_EQUAL(actual1, expected1); + + // A plain message still dispatches, and asking it for arguments is not an + // error; a list with nothing after its head is the very same silence. + Statement stmt2 = make_stmt_from_str("{ 'MOVE TO' -> vase; ['MOVE TO'] -> vase }"); + Capture capture2; + stmt2->execute(); + string expected2 = "the vase stays put\nthe vase stays put\n"; + string actual2 = capture2.getCapture(); + ARCHETYPE_TEST_EQUAL(actual2, expected2); + + // An unclaimed head falls to the default method, as an unclaimed message + // always has; a head that is no kind of message falls there too. + Statement stmt3 = make_stmt_from_str("{ ['SHATTER' 3] -> vase; [42 7] -> vase }"); + Capture capture3; + stmt3->execute(); + string expected3 = "the vase ignores SHATTER\nthe vase ignores 42\n"; + string actual3 = capture3.getCapture(); + ARCHETYPE_TEST_EQUAL(actual3, expected3); + + // The reply comes back from a list message like any other, which is what + // lets a method refuse an argument instead of undoing it + int bump_id = Universe::instance().Messages.index("BUMP"); + vase->setMethod(bump_id, make_stmt_from_str("(head tail message) + 1")); + Expression expr4 = make_expr_from_str("['BUMP' 41] -> vase"); + Value val4 = expr4->evaluate()->numericConversion(); + ARCHETYPE_TEST(val4->isDefined()); + ARCHETYPE_TEST_EQUAL(val4->getNumber(), 42); + + // Passing hands the whole list up, so arguments survive the trip to a + // parent's method without anyone naming them along the way + ObjectPtr furniture = Universe::instance().defineNewObject(); + furniture->setPrototype(true); + Universe::instance().assignObjectIdentifier(furniture, "furniture"); + furniture->setMethod(move_to_id, + make_stmt_from_str("write \"the furniture moves to \", head tail message")); + ObjectPtr crate = Universe::instance().defineNewObject(furniture->id()); + Universe::instance().assignObjectIdentifier(crate, "crate"); + crate->setMethod(move_to_id, + make_stmt_from_str("{ message --> furniture; write \"the crate settles\" }")); + + Statement stmt5 = make_stmt_from_str("['MOVE TO' \"the cellar\"] -> crate"); + Capture capture5; + stmt5->execute(); + string expected5 = "the furniture moves to the cellar\nthe crate settles\n"; + string actual5 = capture5.getCapture(); + ARCHETYPE_TEST_EQUAL(actual5, expected5); + } + void TestObject::testReadDoesNotMutate_() { ObjectPtr subject = Universe::instance().defineNewObject(); Universe::instance().assignObjectIdentifier(subject, "subject"); @@ -207,6 +278,7 @@ namespace archetype { testInheritance_(); testMethods_(); testMessagePassing_(); + testListMessages_(); testReadDoesNotMutate_(); } } diff --git a/src/TestObject.hh b/src/TestObject.hh index 2e373a0..9dc7080 100644 --- a/src/TestObject.hh +++ b/src/TestObject.hh @@ -19,6 +19,7 @@ namespace archetype { void testInheritance_(); void testMethods_(); void testMessagePassing_(); + void testListMessages_(); void testReadDoesNotMutate_(); protected: virtual void runTests_() override; From 2c7602c48de61d42de32d9ad75c7905d82835269 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 13:49:21 -0700 Subject: [PATCH 02/12] Let head and tail bind like the selectors they are They sat at precedence 2, looser than every binary operator, so a prefix head swallowed whatever followed it: "head list = x" took the head of a comparison and quietly handed back UNDEFINED. The TODO left there asked what the right precedence was. It is 12, with length, numeric, string, random and unary minus -- the family they belong to -- so that "head list" is one operand and the operators around it see it that way. Nothing in intrptr.arch changes meaning. Its nineteen uses of head and tail either end an expression, where a prefix operator's precedence cannot matter, or were parenthesized already, which is the fossil of this bug: the parentheses had to be written because the precedence was wrong. Gorreven and Starship compile to byte-identical .acx files across the change, so no expression in either parses differently. "@" stays loose. It builds rather than selects, and an element wants to finish computing before it is joined on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- src/Expression.cc | 11 ++++++++--- src/TestObject.cc | 7 ++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Expression.cc b/src/Expression.cc index 45a0656..d9206f0 100644 --- a/src/Expression.cc +++ b/src/Expression.cc @@ -91,6 +91,11 @@ namespace archetype { case OP_STRING: return 12; case OP_RANDOM: return 12; case OP_LENGTH: return 12; + // Selectors, and they bind like the rest of this family: "head list" + // is one operand, so "head list = x" compares the head rather than + // taking the head of a comparison + case OP_HEAD: return 12; + case OP_TAIL: return 12; case OP_POWER: return 11; @@ -121,10 +126,10 @@ namespace archetype { case OP_AND: return 3; case OP_OR: return 2; - // TODO: What's the right precedence here? LISP never has to decide + // LISP never has to decide this, but a language with infix operators + // does. Building stays loose: whatever computes an element finishes + // before the element is joined on, so "a + 1 @ rest" is a list. case OP_PAIR: return 2; - case OP_HEAD: return 2; - case OP_TAIL: return 2; case OP_C_MULTIPLY: return 1; case OP_C_DIVIDE: return 1; diff --git a/src/TestObject.cc b/src/TestObject.cc index 7284982..ca58972 100644 --- a/src/TestObject.cc +++ b/src/TestObject.cc @@ -170,11 +170,8 @@ namespace archetype { ObjectPtr vase = Universe::instance().defineNewObject(); Universe::instance().assignObjectIdentifier(vase, "vase"); int move_to_id = Universe::instance().Messages.index("MOVE TO"); - // The parentheses around "head tail message" are not decoration: head and - // tail bind more loosely than "=", so without them the comparison would - // happen first, against the message itself vase->setMethod(move_to_id, - make_stmt_from_str("{ if (head tail message) = UNDEFINED then {\n" + make_stmt_from_str("{ if head tail message = UNDEFINED then {\n" " write \"the vase stays put\"\n" " } else {\n" " write \"the vase moves to \", head tail message\n" @@ -211,7 +208,7 @@ namespace archetype { // The reply comes back from a list message like any other, which is what // lets a method refuse an argument instead of undoing it int bump_id = Universe::instance().Messages.index("BUMP"); - vase->setMethod(bump_id, make_stmt_from_str("(head tail message) + 1")); + vase->setMethod(bump_id, make_stmt_from_str("head tail message + 1")); Expression expr4 = make_expr_from_str("['BUMP' 41] -> vase"); Value val4 = expr4->evaluate()->numericConversion(); ARCHETYPE_TEST(val4->isDefined()); From 9d791078bfda175c6a344bf55690f1a5a310ab66 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 13:50:12 -0700 Subject: [PATCH 03/12] Stop parenthesizing head where it no longer needs it Four sites wrapped "head x" only because head used to swallow whatever came after it. With head binding like a selector, the parentheses say nothing, and leaving them in would teach the next author to write them. Line 541 keeps its parentheses. They group a send inside a cons, which is a fact about "->" and "@" rather than about head, and a reader should not have to recall which of those two binds tighter. Gorreven and Starship still compile to the same bytes they did before the precedence change, so none of these lines parses differently than it did when the parentheses were there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- games/intrptr.arch | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/games/intrptr.arch b/games/intrptr.arch index f0b3b28..45870e8 100644 --- a/games/intrptr.arch +++ b/games/intrptr.arch @@ -417,7 +417,7 @@ methods 'SEND EVENT' : { temp := subscribers while temp do { - event -> (head temp) + event -> head temp temp := tail temp } } @@ -489,14 +489,14 @@ methods temp_ := UNDEFINED while members do { if sender ~= head members then { - temp_ := (head members) @ temp_ + temp_ := head members @ temp_ } members := tail members } # temp_ is now members reversed; put it back members := UNDEFINED while temp_ do { - members := (head temp_) @ members + members := head temp_ @ members temp_ := tail temp_ } } @@ -527,7 +527,7 @@ methods temp := sender.members while temp do { if 'INVENTORY NAME' -> head temp then - items := (head temp) @ items + items := head temp @ items temp := tail temp } items From 2acbf5e6a8b6aa7cf6af5ed679725a0a873f0fe0 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 13:53:21 -0700 Subject: [PATCH 04/12] Give a list a string form, so write can show one "write" asks a value for its string, and a pair had none, so writing a list put out nothing whatsoever -- a misrouted list message looked like an empty string rather than like a list. Of the ways to be wrong that is the worst, since it leaves nothing behind to notice. The string form is the printed form: what the REPL echoes and what a message trace shows, so there is one rendering of a list and not two. Its consequences follow the house rule that a value converts if it sensibly can: "length [1 2 3]" is now 7, the width of the printed list, exactly as "length 100" is 3, and lists order lexicographically by that same text where before they did not order at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- src/TestObject.cc | 5 +++-- src/TestValue.cc | 9 +++++++++ src/Value.cc | 11 +++++++++++ src/Value.hh | 2 ++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/TestObject.cc b/src/TestObject.cc index ca58972..3d05a2a 100644 --- a/src/TestObject.cc +++ b/src/TestObject.cc @@ -178,7 +178,7 @@ namespace archetype { " } }\n")); // The default method sees the message the sender wrote, list and all vase->setMethod(DefaultMethod, - make_stmt_from_str("write \"the vase ignores \", head message")); + make_stmt_from_str("write \"the vase ignores \", head message, \" in \", message")); Statement stmt1 = make_stmt_from_str("['MOVE TO' \"the attic\"] -> vase"); Capture capture1; @@ -201,7 +201,8 @@ namespace archetype { Statement stmt3 = make_stmt_from_str("{ ['SHATTER' 3] -> vase; [42 7] -> vase }"); Capture capture3; stmt3->execute(); - string expected3 = "the vase ignores SHATTER\nthe vase ignores 42\n"; + string expected3 = "the vase ignores SHATTER in ['SHATTER' 3]\n" + "the vase ignores 42 in [42 7]\n"; string actual3 = capture3.getCapture(); ARCHETYPE_TEST_EQUAL(actual3, expected3); diff --git a/src/TestValue.cc b/src/TestValue.cc index 2a5de2e..a26606c 100644 --- a/src/TestValue.cc +++ b/src/TestValue.cc @@ -90,6 +90,15 @@ namespace archetype { actual = display(node2); expected = "[\"hello\" \"world\"]"; ARCHETYPE_TEST_EQUAL(actual, expected); + + // A list converts to the string it displays as, so that "write" of one + // shows the list rather than nothing + Value written = node2->stringConversion(); + ARCHETYPE_TEST(written->isDefined()); + ARCHETYPE_TEST_EQUAL(written->getString(), display(node2)); + + Value pair_written = ab->stringConversion(); + ARCHETYPE_TEST_EQUAL(pair_written->getString(), string{"(1 @ 2)"}); } void TestValue::runTests_() { diff --git a/src/Value.cc b/src/Value.cc index 691515a..322706b 100644 --- a/src/Value.cc +++ b/src/Value.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -479,6 +480,16 @@ namespace archetype { return tail_->clone(); } + Value PairValue::stringConversion() const { + // A list's printed form is its string form: the same thing the REPL + // echoes and a message trace shows. Without this, "write" put out + // nothing at all for a list -- the one way of being wrong that leaves + // nothing behind to notice it by. + ostringstream out; + display(out); + return make_unique(out.str()); + } + void PairValue::display(ostream &out) const { const PairValue* tail_p = dynamic_cast(tail_.get()); if (tail_->isDefined() and not tail_p) { diff --git a/src/Value.hh b/src/Value.hh index dc85dc6..7a5a3bd 100644 --- a/src/Value.hh +++ b/src/Value.hh @@ -276,6 +276,8 @@ namespace archetype { virtual Value head() const override; virtual Value tail() const override; + virtual Value stringConversion() const override; + virtual void display(std::ostream& out) const override; virtual std::string asRDF() const override; virtual void write(Storage& out) const override; From 765f3c69371f70b69ba76be55995219e2bdcd032 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 14:03:23 -0700 Subject: [PATCH 05/12] Let the operators that care about structure ask about it A string form for lists gave "length" and the orderings an answer they had no business giving. "length [1 2 3]" measured the printed width, 7, and "[1 2] < [3]" ordered two lists by the text they render as, which is an order in appearance only. Both are the cost of coercion reaching where it does not belong. So they ask instead. "length" of a list counts what is in it -- walked, O(N), since the spine is the only record of how long a list is -- and an improper tail counts as one of them, so "(1 @ 2)" is two. Ordering a list against anything yields UNDEFINED: there is no answer, which is not the same as the answer being no. Equality keeps asking what the list is made of, as it always did, and no longer accepts a list and its own printed text as equal. "&", "write" and "string" go on coercing, because text is what they were asking for in the first place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- src/Expression.cc | 35 +++++++++++++++++++++++++++++++---- src/TestExpression.cc | 30 ++++++++++++++++++++++++++++++ src/Value.cc | 16 ++++++++++++++++ src/Value.hh | 10 ++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/Expression.cc b/src/Expression.cc index d9206f0..bb810fd 100644 --- a/src/Expression.cc +++ b/src/Expression.cc @@ -12,6 +12,7 @@ #include #include #include +#include #include #include "Expression.hh" @@ -305,6 +306,11 @@ namespace archetype { break; } case OP_LENGTH: { + if (rv->isList()) { + // How many things are in it, not how wide it prints + result = make_unique(rv->listLength()); + break; + } Value rv_s = rv->stringConversion(); if (rv_s->isDefined()) { result = make_unique(static_cast(rv_s->getString().size())); @@ -405,6 +411,22 @@ namespace archetype { return make_unique(result); } + // A list compares by what it is made of, or it does not compare. Two lists + // are equal when their elements are, a list and anything else are simply not + // the same value, and no list is less than another: the only ordering on + // offer would be the order of the text they print as, which is an ordering + // in appearance rather than in fact. + optional compare_lists(Keywords::Operators_e op, const Value& lv, const Value& rv) { + if (not (lv->isList() or rv->isList())) { + return nullopt; + } + switch (op) { + case OP_EQ: return as_boolean_value(lv->isSameValueAs(rv)); + case OP_NE: return as_boolean_value(not lv->isSameValueAs(rv)); + default: return Value{make_unique()}; + } + } + bool eval_compare(Keywords::Operators_e op, const Value& lv, const Value& rv) { // Quick shortcut for identity. Will also catch (v = UNDEFINED) and (UNDEFINED = v). if (op == OP_EQ and lv->isSameValueAs(rv)) { @@ -650,11 +672,16 @@ namespace archetype { case OP_LT: case OP_LE: case OP_GE: - case OP_GT: - result = as_boolean_value(eval_compare(op(), - left_->evaluate()->valueConversion(), - right_->evaluate()->valueConversion())); + case OP_GT: { + Value lv_v = left_->evaluate()->valueConversion(); + Value rv_v = right_->evaluate()->valueConversion(); + if (optional as_lists = compare_lists(op(), lv_v, rv_v)) { + result = std::move(*as_lists); + } else { + result = as_boolean_value(eval_compare(op(), lv_v, rv_v)); + } break; + } case OP_ASSIGN: { Value lv_a = left_->evaluate()->attributeConversion(); diff --git a/src/TestExpression.cc b/src/TestExpression.cc index de1aa0f..6a17e44 100644 --- a/src/TestExpression.cc +++ b/src/TestExpression.cc @@ -396,6 +396,36 @@ namespace archetype { // Curly braces in expression position no longer form a list literal. Expression curly_expr = make_expr_from_str("{1 2 3}"); ARCHETYPE_TEST(curly_expr == nullptr); + + // "length" counts what is in a list rather than measuring how wide it + // prints, and it counts an improper tail as one of them. + Value length_val = make_expr_from_str("length [1 2 3]")->evaluate()->numericConversion(); + ARCHETYPE_TEST(length_val->isDefined()); + ARCHETYPE_TEST_EQUAL(length_val->getNumber(), 3); + Value nested_length = make_expr_from_str("length [[1 2] [3 4]]")->evaluate()->numericConversion(); + ARCHETYPE_TEST_EQUAL(nested_length->getNumber(), 2); + Value pair_length = make_expr_from_str("length (1 @ 2)")->evaluate()->numericConversion(); + ARCHETYPE_TEST_EQUAL(pair_length->getNumber(), 2); + Value empty_length = make_expr_from_str("length []")->evaluate()->numericConversion(); + ARCHETYPE_TEST(not empty_length->isDefined()); + + // Equality asks what a list is made of; it does not settle for the two + // of them printing alike. + Value same = make_expr_from_str("[1 2 3] = [1 2 3]")->evaluate()->valueConversion(); + ARCHETYPE_TEST(same->isTrueEnough()); + Value different = make_expr_from_str("[1 2 3] ~= [1 2 4]")->evaluate()->valueConversion(); + ARCHETYPE_TEST(different->isTrueEnough()); + Value list_vs_text = make_expr_from_str("[1 2 3] = \"[1 2 3]\"")->evaluate()->valueConversion(); + ARCHETYPE_TEST(list_vs_text->isDefined()); + ARCHETYPE_TEST(not list_vs_text->isTrueEnough()); + + // Ordering a list against anything is UNDEFINED rather than FALSE: + // there is no answer, which is not the same as the answer being no. + for (auto const& source : {"[1 2] < [3]", "[1 2] > [3]", "[1 2] <= [3]", + "[1 2] >= [3]", "[1 2] < 5", "\"a\" < [1 2]"}) { + Value ordered = make_expr_from_str(source)->evaluate()->valueConversion(); + ARCHETYPE_TEST(not ordered->isDefined()); + } } void TestExpression::testReplDisplay_() { diff --git a/src/Value.cc b/src/Value.cc index 322706b..6cfbfe5 100644 --- a/src/Value.cc +++ b/src/Value.cc @@ -480,6 +480,22 @@ namespace archetype { return tail_->clone(); } + int PairValue::listLength() const { + // Walked rather than remembered, the way std::list::size once was: the + // spine is the only record of how long a list is. + int length = 0; + for (const PairValue* node = this; node; ) { + ++length; + const PairValue* next = dynamic_cast(node->tail_.get()); + if (not next and node->tail_->isDefined()) { + // An improper tail is a thing in the list too: (1 @ 2) holds two + ++length; + } + node = next; + } + return length; + } + Value PairValue::stringConversion() const { // A list's printed form is its string form: the same thing the REPL // echoes and a message trace shows. Without this, "write" put out diff --git a/src/Value.hh b/src/Value.hh index 7a5a3bd..216bf4e 100644 --- a/src/Value.hh +++ b/src/Value.hh @@ -62,6 +62,13 @@ namespace archetype { virtual Value head() const; virtual Value tail() const; + // Not every question about a value is answerable by conversion. An + // operator whose meaning depends on what a value is made of -- how many + // things are in it, whether it can be ordered against another -- has to + // ask, and these are what it asks with. + virtual bool isList() const { return false; } + virtual int listLength() const { return 0; } + virtual Value assign(Value new_value); }; @@ -276,6 +283,9 @@ namespace archetype { virtual Value head() const override; virtual Value tail() const override; + virtual bool isList() const override { return true; } + virtual int listLength() const override; + virtual Value stringConversion() const override; virtual void display(std::ostream& out) const override; From 9b94f9d4c57f97b6c8aac1c71f9594df2f2e51f4 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 15:06:22 -0700 Subject: [PATCH 06/12] Stop the text surgery at the edge of a list "within", "leftfrom" and "rightfrom" were cutting up the form a list prints as: "2" within [1 2 3] found the 2 at character four, and a list sliced into "[1 " and "2 3]". Answers to a question nobody asked. "within" is also where membership would want to live, if lists ever want a membership test, and an operator that already means something cannot quietly come to mean something else. UNDEFINED now keeps that door open; character four would have welded it shut. "&" is untouched, and still asks for the text it always wanted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- src/Expression.cc | 28 ++++++++++++++++++++++++---- src/TestExpression.cc | 13 +++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Expression.cc b/src/Expression.cc index bb810fd..057f148 100644 --- a/src/Expression.cc +++ b/src/Expression.cc @@ -583,8 +583,7 @@ namespace archetype { result = make_unique(std::move(lv_v), std::move(rv_v)); break; } - case OP_CONCAT: - case OP_WITHIN: { + case OP_CONCAT: { Value lv_s = left_->evaluate()->stringConversion(); Value rv_s = right_->evaluate()->stringConversion(); if (lv_s->isDefined() and rv_s->isDefined()) { @@ -594,11 +593,32 @@ namespace archetype { } break; } + // The text surgery operators go no further than text. Cutting up + // the form a list prints as would answer a question nobody asked, + // and "within" is where membership would want to live one day, so + // a list leaves them unanswered rather than answered wrongly. + case OP_WITHIN: { + Value lv_v = left_->evaluate()->valueConversion(); + Value rv_v = right_->evaluate()->valueConversion(); + Value lv_s = lv_v->stringConversion(); + Value rv_s = rv_v->stringConversion(); + if (lv_v->isList() or rv_v->isList()) { + result = make_unique(); + } else if (lv_s->isDefined() and rv_s->isDefined()) { + result = eval_ss(op(), lv_s->getString(), rv_s->getString()); + } else { + result = make_unique(); + } + break; + } case OP_LEFTFROM: case OP_RIGHTFROM: { - Value lv_s = left_->evaluate()->stringConversion(); + Value lv_v = left_->evaluate()->valueConversion(); Value rv_n = right_->evaluate()->numericConversion(); - if (lv_s->isDefined() and rv_n->isDefined()) { + Value lv_s = lv_v->stringConversion(); + if (lv_v->isList()) { + result = make_unique(); + } else if (lv_s->isDefined() and rv_n->isDefined()) { result = eval_sn(op(), lv_s->getString(), rv_n->getNumber()); } else { result = make_unique(); diff --git a/src/TestExpression.cc b/src/TestExpression.cc index 6a17e44..d631f9e 100644 --- a/src/TestExpression.cc +++ b/src/TestExpression.cc @@ -426,6 +426,19 @@ namespace archetype { Value ordered = make_expr_from_str(source)->evaluate()->valueConversion(); ARCHETYPE_TEST(not ordered->isDefined()); } + + // Text surgery stops at the edge of a list rather than cutting up the + // form it prints as, leaving "within" free to mean membership one day. + for (auto const& source : {"\"2\" within [1 2 3]", "[1 2] within \"[1 2 3]\"", + "[1 2 3] leftfrom 3", "[1 2 3] rightfrom 4"}) { + Value surgery = make_expr_from_str(source)->evaluate()->valueConversion(); + ARCHETYPE_TEST(not surgery->isDefined()); + } + + // But "&" still asks for text, and a list still has some + Value joined = make_expr_from_str("\"items \" & [1 2 3]")->evaluate()->stringConversion(); + ARCHETYPE_TEST(joined->isDefined()); + ARCHETYPE_TEST_EQUAL(joined->getString(), string{"items [1 2 3]"}); } void TestExpression::testReplDisplay_() { From c0a6aa7b63aa28c71c1052e5b55d59698d151d1c Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 16:51:29 -0700 Subject: [PATCH 07/12] Demonstrate a move that can be refused The shipped protocol tells a thing about a move after the move; this one asks first, because the destination rides in the message. Nothing here includes the standard library -- the protocol is the whole program -- and nothing here touches the 'MOVE' protocol that shipped games subclass. The thing class has no last_location and needs none: when ['MOVE TO' dest] arrives, location is still the origin. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- demos/README.md | 58 ++++++++++++ demos/moving.arch | 230 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 demos/moving.arch diff --git a/demos/README.md b/demos/README.md index 39fc008..9912f62 100644 --- a/demos/README.md +++ b/demos/README.md @@ -75,3 +75,61 @@ It reads the Turtle that `--inspect` emits, in the shape it emits it. It is not a general Turtle parser, and it will not do anything sensible with Turtle from somewhere else. For real queries, load the dump into a triplestore and use SPARQL; this exists to make a picture. + +## moving.arch — a message that carries its destination + +```shell +./build/archetype --source=demos/moving.arch +``` + +It includes nothing, parses nothing, and asks for no input. The protocol is the +whole program. + +A message can dispatch on the head of a list, which lets the rest of the list +be arguments: `['MOVE TO' socket] -> bulb`. What that buys is visible in the +one place a text adventure feels it most. The shipped protocol in +`intrptr.arch` moves a thing in two steps — write the new location into it, +then send it `'MOVE'` — so the thing is told about the move only after the move +has happened. It recovers the origin from `last_location`, which it kept for +exactly that purpose, and a handler that wants to refuse can only put things +back afterward. `starship_types.arch` has the canonical version: a power source +moved into an occupied socket complains and writes `location := last_location`. + +When the destination arrives in the message, `location` is still the origin, so +both ends of the move are in hand at once. The demo's `thing` class has no +`last_location` and needs none. A refusal is an answer: + +``` + if not ['WILL ACCEPT' self] -> dest_ then + FALSE +``` + +and the caller reads it as one, because a send is an expression: + +``` + if not ['MOVE TO' hand] -> bulb then + write "So the bulb is still in the socket." +``` + +Three smaller things the demo is built to show: + +- **`'ADD SELF'` and `'DROP SELF'` sharpen rather than dissolve.** They were + always argument-passing, over a channel that holds exactly one argument: + `sender`. Spelling them `['ACCEPT' thing]` and `['RELEASE' thing]` separates + who is asking from what is moving, and makes room for the message that could + not exist before — `'WILL ACCEPT'`, the question. Note where the capacity + check ends up: in `intrptr.arch` the mover reaches into the destination + (`if location.capacity then location.capacity -:= size`) because it has no + way to ask; here the place answers for itself. +- **Forwarding is free.** `announced` overrides `'MOVE TO'`, and + `message --> thing` hands the whole list to the parent, arguments and all, + with nothing unpacked and nothing rebuilt. +- **Assembly is not movement.** `'ASSEMBLE'` in `intrptr.arch` clears + `last_location` and calls `'MOVE'`, using the field as a "never placed" flag. + With no such field there is nothing to clear, so the demo gives the initial + placement its own message. That is the one job `last_location` was doing that + was not smuggling. + +It is a demonstration of a protocol, not a replacement for one. The `'MOVE'` +protocol in `intrptr.arch` is subclassed by shipped games and is not going +anywhere. diff --git a/demos/moving.arch b/demos/moving.arch new file mode 100644 index 0000000..8a2f3b4 --- /dev/null +++ b/demos/moving.arch @@ -0,0 +1,230 @@ +# MOVING.ARCH +# +# A message that carries its destination, and what that changes. +# +# The shipped protocol in intrptr.arch moves a thing in two steps: write +# the new location into it, then tell it that you did. It works out where +# it came from by having kept the old value in "last_location", because by +# the time it hears about the move there is no other record of the origin. +# A guard can therefore only run after the mutation, and refusing means +# putting everything back. +# +# Here the destination rides along in the message, so both ends of the move +# are in hand at once and a refusal is just an answer. +# +# Nothing here includes the standard library: the protocol is the whole +# program. Run it with +# +# ./build/archetype --source=demos/moving.arch +# +# It demonstrates a protocol. It does not replace the one in intrptr.arch, +# which shipped games subclass. + + +############################################################################# +# Places +# +# A place is asked to take something and asked to give it up. It is never +# told after the fact, so it can still say no while no still means +# something. + +class place based on null + + name : "somewhere" + contents : UNDEFINED + + # The first thing a handler wants is a name for its argument. There are + # no locals, so that name is an attribute. + it_ : UNDEFINED + rest_ : UNDEFINED + +methods + + # Asked before anything has moved. A place with a reason to refuse + # overrides this one and gives it. + 'WILL ACCEPT' : TRUE + + 'ACCEPT' : contents := head tail message @ contents + + 'RELEASE' : { + it_ := head tail message + rest_ := UNDEFINED + while contents do { + if head contents ~= it_ then rest_ := head contents @ rest_ + contents := tail contents + } + while rest_ do { + contents := head rest_ @ contents + rest_ := tail rest_ + } + } + + 'ROLL CALL' : { + writes " ", name, " holds:" + rest_ := contents + if rest_ = UNDEFINED then writes " nothing" + while rest_ do { + writes " ", (head rest_).name + rest_ := tail rest_ + } + write "" + } + +end + + +############################################################################# +# Things +# +# A thing knows where it is. It never knows where it was, because it is +# never asked to work that out: when ['MOVE TO' dest] arrives, "location" +# is still the origin and the destination is in the message. That is the +# whole reason there is no "last_location" here. + +class thing based on null + + name : "something" + location : UNDEFINED + + dest_ : UNDEFINED + +methods + + # Assembly is not movement. "location" was written down in the source + # and the place has never heard of it. The shipped protocol fakes this + # up by clearing last_location and calling it a move; with no such + # attribute there is nothing to clear, so it gets its own message. + 'PLACE' : if location then ['ACCEPT' self] -> location + + 'MOVE TO' : { + dest_ := head tail message + if dest_ = location then + TRUE + else if not ['WILL ACCEPT' self] -> dest_ then + FALSE + else { + ['RELEASE' self] -> location + ['ACCEPT' self] -> dest_ + location := dest_ + TRUE + } + } + +end + + +# Forwarding is free: the whole list goes up the chain, arguments and all, +# and by the time the parent is done "location" is the destination. + +class announced based on thing + +methods + + 'MOVE TO' : + if message --> thing then { + write name, " goes to ", location.name, "." + TRUE + } + else + FALSE + +end + + +############################################################################# +# Two places with reasons to refuse + +place bench name : "the workbench" end + + +# starship_types.arch does this check the other way round: the power source +# looks at location.contains *after* being moved into it, complains, and +# writes location := last_location to undo the move. Here the socket +# answers for its own occupancy, before anything happens. + +place socket + + name : "the socket" + +methods + + 'WILL ACCEPT' : + if contents then { + write "There is already something in the socket." + FALSE + } + else + TRUE + +end + + +place hand + + name : "my hand" + capacity : 2 + +methods + + 'WILL ACCEPT' : + # An empty list has no length -- it reads as UNDEFINED, the same as an + # attribute nobody set -- so emptiness is tested before size. + if contents and length contents >= capacity then { + write "My hand is full." + FALSE + } + else + TRUE + +end + + +thing bulb name : "the bulb" location : bench end +thing fuse name : "the fuse" location : bench end +announced wrench name : "the wrench" location : bench end + + +############################################################################# + +null main + +methods + + 'ROLL CALL' : { + 'ROLL CALL' -> bench + 'ROLL CALL' -> socket + 'ROLL CALL' -> hand + write "" + } + + 'START' : { + + for each do 'PLACE' -> each + + write "Everything starts on the bench." + 'ROLL CALL' + + write "Putting the bulb in the socket." + if ['MOVE TO' socket] -> bulb then + write "Done." + 'ROLL CALL' + + write "Putting the fuse in the socket, which is spoken for." + if not ['MOVE TO' socket] -> fuse then + write "So the fuse never left the bench." + 'ROLL CALL' + + write "Picking up the fuse and the wrench." + ['MOVE TO' hand] -> fuse + ['MOVE TO' hand] -> wrench + 'ROLL CALL' + + write "Reaching for the bulb with both hands full." + if not ['MOVE TO' hand] -> bulb then + write "So the bulb is still in the socket." + 'ROLL CALL' + + stop "Nothing was moved and put back." + } + +end From 5d072fae22c02074df489370ce70023a2de6c34b Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Thu, 13 Aug 2026 20:09:00 -0700 Subject: [PATCH 08/12] Ask first, and only ask for what sender cannot say A precondition that is ABSENT for everything without an objection covers nearly all of this, at no cost to the common case, and 'ADD SELF' wants no argument because the thing being added is the thing asking. What is left over is one question -- whether the destination will have it -- and the destination is the one thing sender cannot name, since sender is already saying which thing is moving. So the demo now spends exactly one list message, and one attribute to name its argument. The vise is the other half of the point: bolted down needs no argument at all. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUmuc82NHf76PrRYjgLhq5 --- demos/README.md | 71 ++++++++++++++++------------ demos/moving.arch | 117 +++++++++++++++++++++++++++++----------------- 2 files changed, 114 insertions(+), 74 deletions(-) diff --git a/demos/README.md b/demos/README.md index 9912f62..36f29ab 100644 --- a/demos/README.md +++ b/demos/README.md @@ -76,7 +76,7 @@ a general Turtle parser, and it will not do anything sensible with Turtle from somewhere else. For real queries, load the dump into a triplestore and use SPARQL; this exists to make a picture. -## moving.arch — a message that carries its destination +## moving.arch — preconditions, and the one argument left over ```shell ./build/archetype --source=demos/moving.arch @@ -85,50 +85,61 @@ SPARQL; this exists to make a picture. It includes nothing, parses nothing, and asks for no input. The protocol is the whole program. -A message can dispatch on the head of a list, which lets the rest of the list -be arguments: `['MOVE TO' socket] -> bulb`. What that buys is visible in the -one place a text adventure feels it most. The shipped protocol in -`intrptr.arch` moves a thing in two steps — write the new location into it, -then send it `'MOVE'` — so the thing is told about the move only after the move -has happened. It recovers the origin from `last_location`, which it kept for -exactly that purpose, and a handler that wants to refuse can only put things -back afterward. `starship_types.arch` has the canonical version: a power source -moved into an occupied socket complains and writes `location := last_location`. +The shipped protocol in `intrptr.arch` moves a thing in two steps — write the +new location into it, then send it `'MOVE'` — so the thing is told about the +move only after the move has happened. It recovers the origin from +`last_location`, kept for exactly that purpose, and a handler that wants to +refuse can only put things back afterward. `starship_types.arch` has the +canonical version: a power source moved into an occupied socket complains and +writes `location := last_location`. -When the destination arrives in the message, `location` is still the origin, so -both ends of the move are in hand at once. The demo's `thing` class has no -`last_location` and needs none. A refusal is an answer: +Nearly all of that is fixed by asking first, and asking needs no arguments. +A precondition is a method that is ABSENT for everything with no objection — +which is almost everything — so only the rare exception mentions it at all: ``` - if not ['WILL ACCEPT' self] -> dest_ then - FALSE + 'CANNOT MOVE' : ABSENT ``` -and the caller reads it as one, because a send is an expression: +ABSENT is already false enough to fall through an `if`, so the common case +costs nothing and stays invisible. The vise in the demo is the exception, and +it is the entire implementation of being bolted down. + +`'ADD SELF'` and `'DROP SELF'` are unchanged, and want no arguments either: the +thing being added is the thing doing the asking, so `sender` already names it. + +**One thing is left over**, and it is the only list message in the file. A +precondition about the *destination* has to be given the destination, and a +thing has exactly one channel for that — `sender` — which is already saying +which thing is moving. So the destination rides in the message: ``` - if not ['MOVE TO' hand] -> bulb then - write "So the bulb is still in the socket." + 'MOVE TO' : { + dest_ := head tail message + if dest_ = location then TRUE + else if 'CANNOT MOVE' -> self then FALSE + else if 'CANNOT ACCEPT' -> dest_ then FALSE + else { ... } + } ``` -Three smaller things the demo is built to show: +That is the whole case for arguments, and the whole price of them: one +attribute, `dest_`, in one handler, to give the argument a name. Everything +else in the protocol was already expressible. + +Two smaller things it shows: -- **`'ADD SELF'` and `'DROP SELF'` sharpen rather than dissolve.** They were - always argument-passing, over a channel that holds exactly one argument: - `sender`. Spelling them `['ACCEPT' thing]` and `['RELEASE' thing]` separates - who is asking from what is moving, and makes room for the message that could - not exist before — `'WILL ACCEPT'`, the question. Note where the capacity - check ends up: in `intrptr.arch` the mover reaches into the destination - (`if location.capacity then location.capacity -:= size`) because it has no - way to ask; here the place answers for itself. - **Forwarding is free.** `announced` overrides `'MOVE TO'`, and `message --> thing` hands the whole list to the parent, arguments and all, with nothing unpacked and nothing rebuilt. - **Assembly is not movement.** `'ASSEMBLE'` in `intrptr.arch` clears `last_location` and calls `'MOVE'`, using the field as a "never placed" flag. - With no such field there is nothing to clear, so the demo gives the initial - placement its own message. That is the one job `last_location` was doing that - was not smuggling. + With the guards moved ahead of the mutation there is no such field, so the + initial placement gets its own message. + +Worth noticing where the capacity check ends up. In `intrptr.arch` the mover +reaches into the destination — `if location.capacity then location.capacity -:= +size` — because it has no way to ask. Here the place answers for itself. It is a demonstration of a protocol, not a replacement for one. The `'MOVE'` protocol in `intrptr.arch` is subclassed by shipped games and is not going diff --git a/demos/moving.arch b/demos/moving.arch index 8a2f3b4..345ed34 100644 --- a/demos/moving.arch +++ b/demos/moving.arch @@ -1,16 +1,23 @@ # MOVING.ARCH # -# A message that carries its destination, and what that changes. +# Where a message with arguments earns its keep, and where it does not. # # The shipped protocol in intrptr.arch moves a thing in two steps: write -# the new location into it, then tell it that you did. It works out where -# it came from by having kept the old value in "last_location", because by -# the time it hears about the move there is no other record of the origin. -# A guard can therefore only run after the mutation, and refusing means -# putting everything back. +# the new location into it, then send it 'MOVE'. The thing hears about the +# move only after the move, works out where it came from by having kept +# "last_location" for that purpose, and a handler that wants to refuse can +# only put things back afterward. # -# Here the destination rides along in the message, so both ends of the move -# are in hand at once and a refusal is just an answer. +# Almost all of that is fixed by asking first, and asking needs no +# arguments at all. A precondition is a method that is ABSENT for the +# things that have no objection -- which is nearly all of them -- and only +# the rare exception says otherwise. ABSENT is already false enough to +# fall through an "if", so the common case costs nothing and is invisible. +# +# One thing is left over, and it is the only list message in this file. A +# precondition about the *destination* has to be given the destination, and +# a thing has exactly one channel for that -- "sender" -- which is already +# saying which thing is moving. So the destination comes in the message. # # Nothing here includes the standard library: the protocol is the whole # program. Run it with @@ -24,33 +31,29 @@ ############################################################################# # Places # -# A place is asked to take something and asked to give it up. It is never -# told after the fact, so it can still say no while no still means -# something. +# 'ADD SELF' and 'DROP SELF' are unchanged from the shipped protocol, and +# want no arguments: the thing being added is the thing doing the asking, +# so "sender" already names it. class place based on null name : "somewhere" contents : UNDEFINED - # The first thing a handler wants is a name for its argument. There are - # no locals, so that name is an attribute. - it_ : UNDEFINED rest_ : UNDEFINED methods - # Asked before anything has moved. A place with a reason to refuse - # overrides this one and gives it. - 'WILL ACCEPT' : TRUE + # The precondition. Nearly every place has no opinion, and says so by + # never mentioning it. + 'CANNOT ACCEPT' : ABSENT - 'ACCEPT' : contents := head tail message @ contents + 'ADD SELF' : contents := sender @ contents - 'RELEASE' : { - it_ := head tail message + 'DROP SELF' : { rest_ := UNDEFINED while contents do { - if head contents ~= it_ then rest_ := head contents @ rest_ + if head contents ~= sender then rest_ := head contents @ rest_ contents := tail contents } while rest_ do { @@ -76,35 +79,42 @@ end ############################################################################# # Things # -# A thing knows where it is. It never knows where it was, because it is +# A thing knows where it is and never knows where it was, because it is # never asked to work that out: when ['MOVE TO' dest] arrives, "location" -# is still the origin and the destination is in the message. That is the -# whole reason there is no "last_location" here. +# is still the origin. Both preconditions run before anything is written, +# so a refusal leaves nothing to undo -- and there is no last_location. class thing based on null name : "something" location : UNDEFINED + # The argument, named. This is the whole cost of the list message, and + # it is one attribute, in one class, in the one handler that takes an + # argument. dest_ : UNDEFINED methods + # The other precondition: whether this thing can move at all, which has + # nothing to do with where it is going and so needs nothing passed in. + 'CANNOT MOVE' : ABSENT + # Assembly is not movement. "location" was written down in the source - # and the place has never heard of it. The shipped protocol fakes this - # up by clearing last_location and calling it a move; with no such - # attribute there is nothing to clear, so it gets its own message. - 'PLACE' : if location then ['ACCEPT' self] -> location + # and the place has never heard of it. + 'PLACE' : if location then 'ADD SELF' -> location 'MOVE TO' : { dest_ := head tail message if dest_ = location then TRUE - else if not ['WILL ACCEPT' self] -> dest_ then + else if 'CANNOT MOVE' -> self then + FALSE + else if 'CANNOT ACCEPT' -> dest_ then FALSE else { - ['RELEASE' self] -> location - ['ACCEPT' self] -> dest_ + 'DROP SELF' -> location + 'ADD SELF' -> dest_ location := dest_ TRUE } @@ -132,15 +142,15 @@ end ############################################################################# -# Two places with reasons to refuse +# The exceptions, which are few and say so themselves place bench name : "the workbench" end -# starship_types.arch does this check the other way round: the power source -# looks at location.contains *after* being moved into it, complains, and -# writes location := last_location to undo the move. Here the socket -# answers for its own occupancy, before anything happens. +# starship_types.arch has this the other way round: the power source is +# moved into an occupied socket, notices afterward, complains, and writes +# location := last_location to undo it. Asked first, the socket answers +# for its own occupancy and nothing needs undoing. place socket @@ -148,13 +158,11 @@ place socket methods - 'WILL ACCEPT' : + 'CANNOT ACCEPT' : if contents then { write "There is already something in the socket." - FALSE - } - else TRUE + } end @@ -166,15 +174,13 @@ place hand methods - 'WILL ACCEPT' : + 'CANNOT ACCEPT' : # An empty list has no length -- it reads as UNDEFINED, the same as an # attribute nobody set -- so emptiness is tested before size. if contents and length contents >= capacity then { write "My hand is full." - FALSE - } - else TRUE + } end @@ -184,6 +190,24 @@ thing fuse name : "the fuse" location : bench end announced wrench name : "the wrench" location : bench end +# The destination-independent refusal, which is the common kind and wants +# no message with arguments at all. + +thing vise + + name : "the vise" + location : bench + +methods + + 'CANNOT MOVE' : { + write "The vise is bolted to the bench." + TRUE + } + +end + + ############################################################################# null main @@ -214,6 +238,11 @@ methods write "So the fuse never left the bench." 'ROLL CALL' + write "Picking up the vise, which is not going anywhere." + if not ['MOVE TO' hand] -> vise then + write "No argument was needed to find that out." + 'ROLL CALL' + write "Picking up the fuse and the wrench." ['MOVE TO' hand] -> fuse ['MOVE TO' hand] -> wrench From c7882e532261c32bd70ba6efde016d73ee6fa91d Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Tue, 18 Aug 2026 18:41:45 -0700 Subject: [PATCH 09/12] Let an atom be the head of itself Every value is now a list of at least one. A bare message and ['MOVE TO'] were already the same silence to a handler asking for arguments; now they are the same to everyone: dispatch always goes by the head, and the second case in Object::dispatch() is gone. A default method gets one spelling of the question, too. "case head message of" names what was sent whether or not it arrived in a list; before, each kind of message needed its own case, because the head of an atom was UNDEFINED. The tail stays undefined for an atom, on purpose. It is what tells an atom from a list of one, and it is what lets every loop that walks a list find the end. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj --- src/Object.cc | 13 +++++-------- src/TestExpression.cc | 15 +++++++++++++++ src/TestObject.cc | 10 +++++++--- src/Value.cc | 7 ++++++- src/Value.hh | 2 ++ 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/Object.cc b/src/Object.cc index a54a1d1..960f2e3 100644 --- a/src/Object.cc +++ b/src/Object.cc @@ -75,14 +75,11 @@ namespace archetype { Value Object::dispatch() { const Value& sent = Universe::instance().currentContext().messageValue; - Value defined_message = sent->messageConversion(); - if (not defined_message->isDefined()) { - // A list dispatches on its head, so that ['MOVE TO' player] finds the - // 'MOVE TO' method and the rest of the list rides along as arguments. - // Nothing that dispatched before dispatches differently now: every - // value that is not a pair has an undefined head. - defined_message = sent->head()->messageConversion(); - } + // Dispatch always goes by the head. A list supplies its own, so that + // ['MOVE TO' player] finds the 'MOVE TO' method and the rest rides + // along as arguments; an atom is the head of itself, so a bare message + // is a list of one and there is no second case. + Value defined_message = sent->head()->messageConversion(); Value absence = make_unique(); Value result = make_unique(); int message_id = -1; diff --git a/src/TestExpression.cc b/src/TestExpression.cc index d631f9e..d3788c2 100644 --- a/src/TestExpression.cc +++ b/src/TestExpression.cc @@ -439,6 +439,21 @@ namespace archetype { Value joined = make_expr_from_str("\"items \" & [1 2 3]")->evaluate()->stringConversion(); ARCHETYPE_TEST(joined->isDefined()); ARCHETYPE_TEST_EQUAL(joined->getString(), string{"items [1 2 3]"}); + + // An atom is the head of itself: every value is a list of at least + // one. The tail is what tells an atom from a list of one, and the + // head of nothing is still nothing. + Value atom_head = make_expr_from_str("head 5")->evaluate()->numericConversion(); + ARCHETYPE_TEST(atom_head->isDefined()); + ARCHETYPE_TEST_EQUAL(atom_head->getNumber(), 5); + Value string_head = make_expr_from_str("head \"abc\"")->evaluate()->stringConversion(); + ARCHETYPE_TEST_EQUAL(string_head->getString(), string{"abc"}); + Value single = make_expr_from_str("head [5] = head 5")->evaluate()->valueConversion(); + ARCHETYPE_TEST(single->isTrueEnough()); + Value atom_tail = make_expr_from_str("tail 5")->evaluate()->valueConversion(); + ARCHETYPE_TEST(not atom_tail->isDefined()); + Value undef_head = make_expr_from_str("head UNDEFINED")->evaluate()->valueConversion(); + ARCHETYPE_TEST(not undef_head->isDefined()); } void TestExpression::testReplDisplay_() { diff --git a/src/TestObject.cc b/src/TestObject.cc index 3d05a2a..c932a7a 100644 --- a/src/TestObject.cc +++ b/src/TestObject.cc @@ -197,12 +197,16 @@ namespace archetype { ARCHETYPE_TEST_EQUAL(actual2, expected2); // An unclaimed head falls to the default method, as an unclaimed message - // always has; a head that is no kind of message falls there too. - Statement stmt3 = make_stmt_from_str("{ ['SHATTER' 3] -> vase; [42 7] -> vase }"); + // always has; a head that is no kind of message falls there too. And + // an atom is the head of itself, so "head message" names the message + // whether or not it arrived in a list: one spelling of the question. + Statement stmt3 = make_stmt_from_str("{ ['SHATTER' 3] -> vase; [42 7] -> vase;" + " 'VANISH' -> vase }"); Capture capture3; stmt3->execute(); string expected3 = "the vase ignores SHATTER in ['SHATTER' 3]\n" - "the vase ignores 42 in [42 7]\n"; + "the vase ignores 42 in [42 7]\n" + "the vase ignores VANISH in VANISH\n"; string actual3 = capture3.getCapture(); ARCHETYPE_TEST_EQUAL(actual3, expected3); diff --git a/src/Value.cc b/src/Value.cc index 6cfbfe5..f58cf7d 100644 --- a/src/Value.cc +++ b/src/Value.cc @@ -110,10 +110,15 @@ namespace archetype { } Value IValue::head() const { - return make_unique(); + // An atom is the head of itself: every value reads as a list of at + // least one, which is what lets dispatch always go by the head. + return clone(); } Value IValue::tail() const { + // The tail is what tells an atom from a list of one, and it stays + // undefined here on purpose: if an atom were its own tail as well, + // every loop that walks a list would never find the end of it. return make_unique(); } diff --git a/src/Value.hh b/src/Value.hh index 216bf4e..4815c1b 100644 --- a/src/Value.hh +++ b/src/Value.hh @@ -59,6 +59,8 @@ namespace archetype { virtual Value attributeConversion() const; virtual Value valueConversion() const { return clone(); } + // Every value is a list of at least itself: an atom's head is the + // atom, and only its undefined tail tells it from a list of one. virtual Value head() const; virtual Value tail() const; From 736ccd4a1523b7b7c9721bde95587a7fdef34428 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Tue, 18 Aug 2026 18:46:44 -0700 Subject: [PATCH 10/12] Send system the whole dance in one message A list message to system is the staged dance in one send: the head first, then each element of the tail in order, with the reply of the whole being the reply of the last. So ['LOAD STATE' "file.acx"] -> system keeps its Boolean with no state machine on the sending side, and a control message rides in a tail like any other element, so a whole open-feed-close protocol fits in one send. Bare messages walk the same walk in one step -- an atom is the head of itself with an undefined tail -- so every staged protocol plays on unchanged. And a one-shot can never be caught between states by a save. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj --- src/SystemObject.cc | 16 ++++++++++++- src/SystemObject.hh | 1 + src/TestSystemObject.cc | 53 +++++++++++++++++++++++++++++++++++++++++ src/TestSystemObject.hh | 1 + 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/SystemObject.cc b/src/SystemObject.cc index dfda4c9..8a86c9a 100644 --- a/src/SystemObject.cc +++ b/src/SystemObject.cc @@ -67,7 +67,21 @@ namespace archetype { } Value SystemObject::executeDefaultMethod() { - Value message = Universe::instance().currentContext().messageValue->clone(); + const Value& sent = Universe::instance().currentContext().messageValue; + // A list message is the staged dance in one send: the head first, + // then each element of the tail in order, with the reply of the whole + // being the reply of the last. So ['LOAD STATE' "file.acx"] keeps + // its Boolean, and no send can be caught mid-dance by a save. An + // atom is the head of itself with an undefined tail, so a bare + // message walks the same walk in one step. + Value result = interpret_(sent->head()); + for (Value node = sent->tail(); node->isDefined(); node = node->tail()) { + result = interpret_(node->head()); + } + return result; + } + + Value SystemObject::interpret_(const Value& message) { switch (state_) { case IDLING: if (figureState_(message)) { diff --git a/src/SystemObject.hh b/src/SystemObject.hh index 57a890e..7dc4593 100644 --- a/src/SystemObject.hh +++ b/src/SystemObject.hh @@ -53,6 +53,7 @@ namespace archetype { std::unique_ptr parser_; bool figureState_(const Value& message); + Value interpret_(const Value& message); void resetSystem_(); friend void write_parser_rdf(std::ostream& out, bool with_prefixes); diff --git a/src/TestSystemObject.cc b/src/TestSystemObject.cc index b1214c3..f1455d5 100644 --- a/src/TestSystemObject.cc +++ b/src/TestSystemObject.cc @@ -209,10 +209,63 @@ namespace archetype { ARCHETYPE_TEST(two_statements->isSameValueAs(take_obj)); } + // A list message is the staged dance in one send: the head first, then + // each element of the tail in order, with the reply of the whole being + // the reply of the last. + void TestSystemObject::testListMessages_() { + Universe::destroy(); + + // The sorter loaded in one message. The reply is the last add's + // echo, the same value the staged dance would have ended on. + Value last = make_stmt_from_str( + "['INIT SORTER' \"dog\" \"Ajax\" \"cat\"] -> system" + )->execute()->stringConversion(); + ARCHETYPE_TEST(last->isDefined()); + ARCHETYPE_TEST_EQUAL(last->getString(), string{"cat"}); + make_stmt_from_str("'CLOSE SORTER' -> system")->execute(); + deque expected = {"Ajax", "cat", "dog"}; + Statement stmt = make_stmt_from_str("'NEXT SORTED' -> system"); + for (auto const& s : expected) { + Value ans = stmt->execute()->stringConversion(); + ARCHETYPE_TEST(ans->isDefined()); + ARCHETYPE_TEST_EQUAL(ans->getString(), s); + } + ARCHETYPE_TEST(not stmt->execute()->isDefined()); + + // A list of one is the bare message, to system like to everything. + Value singleton = make_stmt_from_str("['NEXT SORTED'] -> system")->execute(); + ARCHETYPE_TEST(not singleton->isDefined()); + + // A control message rides in a tail like any other element, so a + // whole open-feed-close protocol fits in one send. + make_stmt_from_str("['INIT SORTER' \"b\" \"a\" 'CLOSE SORTER'] -> system")->execute(); + Value first = make_stmt_from_str("'NEXT SORTED' -> system")->execute()->stringConversion(); + ARCHETYPE_TEST(first->isDefined()); + ARCHETYPE_TEST_EQUAL(first->getString(), string{"a"}); + make_stmt_from_str("'NEXT SORTED' -> system")->execute(); + ARCHETYPE_TEST(not make_stmt_from_str("'NEXT SORTED' -> system")->execute()->isDefined()); + + // One-shot 'WHICH OBJECT': no priming, no second send, and the + // answer comes back as the reply of the only send there is. + TokenStream t1(make_source_from_str("program1", program1)); + Universe::instance().make(t1); + make_stmt_from_str( + "{'OPEN PARSER' -> system;" + "'BUILD' -> take;" + "'BUILD' -> money;" + "'CLOSE PARSER' -> system}" + )->execute(); + int take_obj_id = Universe::instance().getObject("take")->id(); + Value one_shot = make_stmt_from_str("['WHICH OBJECT' \"grab\"] -> system")->execute(); + Value take_obj = make_unique(take_obj_id); + ARCHETYPE_TEST(one_shot->isSameValueAs(take_obj)); + } + void TestSystemObject::runTests_() { testSorting_(); testParsing_(); testEmptyPhraseNeverMatches_(); testArrowSequencing_(); + testListMessages_(); } } diff --git a/src/TestSystemObject.hh b/src/TestSystemObject.hh index 0c64efb..e3d3212 100644 --- a/src/TestSystemObject.hh +++ b/src/TestSystemObject.hh @@ -19,6 +19,7 @@ namespace archetype { void testParsing_(); void testEmptyPhraseNeverMatches_(); void testArrowSequencing_(); + void testListMessages_(); protected: virtual void runTests_() override; public: From b5db11bac3e382a16f76c820f0a60061daa78286 Mon Sep 17 00:00:00 2001 From: "Derek T. Jones" Date: Tue, 18 Aug 2026 18:57:37 -0700 Subject: [PATCH 11/12] Say in one send what intrptr always meant as one Every place that primed system and then sent the argument on the next line now asks the whole question at once: five 'WHICH OBJECT' dances, the 'PLAYER CMD' pair, both 'BANNER' pairs, and the save and load prompts, whose filename now rides in the message that wants it -- if ['LOAD STATE' read] -> system then What stays staged, stays for a reason. The vocabulary dances feed system from other objects' 'NAME' methods, where each send's sender is the word's owner, and the sorter is fed from a loop; both are genuinely many sends, which is what the staged form is for. The goldens moved by ten bytes of statements and not one line of Turtle: the world the games compile to is identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj --- games/intrptr.arch | 28 ++++++++++------------------ tests/golden/bare.acx | Bin 14855 -> 14865 bytes tests/golden/cherry.acx | Bin 11473 -> 11483 bytes 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/games/intrptr.arch b/games/intrptr.arch index 45870e8..2b7b487 100644 --- a/games/intrptr.arch +++ b/games/intrptr.arch @@ -192,8 +192,7 @@ methods stop "EOF; goodbye." } - 'PLAYER CMD' -> system - command -> system + ['PLAYER CMD' command] -> system 'SEND EVENT' -> before @@ -273,14 +272,12 @@ methods # as a message. if send_to then { - 'WHICH OBJECT' -> system - if (match := verb -> system).interpret then + if (match := ['WHICH OBJECT' verb] -> system).interpret then 'INTERPRET' -> match else if match.disabled then 'DISABLED' -> match else { - 'WHICH OBJECT' -> system - if (match := verbmsg -> system).interpret then + if (match := ['WHICH OBJECT' verbmsg] -> system).interpret then 'INTERPRET' -> match else if match.disabled then 'DISABLED' -> match @@ -294,8 +291,7 @@ methods # Now it's time to actually send the message. If who we're sending to has # an ABSENT method, give the appropriate error messages. if verbmsg -> send_to = ABSENT then { - 'WHICH OBJECT' -> system - if (match := verbmsg -> system) and match.normal then + if (match := ['WHICH OBJECT' verbmsg] -> system) and match.normal then { if 'NORMAL' -> match = ABSENT then 'Semantic Error' } else { @@ -315,8 +311,7 @@ methods else answer := ABSENT if answer = ABSENT then { # try the verb alone - 'WHICH OBJECT' -> system - if (match := verb -> system).IsAVerb and match.normal then + if (match := ['WHICH OBJECT' verb] -> system).IsAVerb and match.normal then { if 'NORMAL' -> match = ABSENT then 'Semantic Error' } else 'Semantic Error' @@ -361,8 +356,7 @@ methods 'MENTION' : { mentioned := TRUE - 'WHICH OBJECT' -> system - if it := sender.pronoun -> system then + if it := ['WHICH OBJECT' sender.pronoun] -> system then it.referent := sender else { create pronoun_object named it @@ -676,12 +670,12 @@ methods } 'ROOMVIEW' : { - 'BANNER' -> system; '-' -> system + ['BANNER' '-'] -> system if not visited then 'FIRSTDESC' 'LONGDESC' 'INVENTORY' 'INVENTORY' -> compass - 'BANNER' -> system; '-' -> system + ['BANNER' '-'] -> system } # ROOMVIEW 'BRIEF' : @@ -890,8 +884,7 @@ methods 'save' : { writes "Save current state to what file? " - 'SAVE STATE' -> system - if read -> system then + if ['SAVE STATE' read] -> system then write "Game saved." else write "Could not save game." @@ -899,8 +892,7 @@ methods 'load' : { writes "Load game from what file? " - 'LOAD STATE' -> system - if read -> system then + if ['LOAD STATE' read] -> system then write "Game loaded." else write "Could not load game." diff --git a/tests/golden/bare.acx b/tests/golden/bare.acx index c99909a60b975ee9d75c10e1affcb2bd164cc785..dfab62ccf100086f38dcd10284d4e87dc98d8e3e 100644 GIT binary patch delta 291 zcmZoKnOL%+QC^gZg++`-hRuORgoS~F!+?c>&5eU$@^N`tF$PSjAh1-7I8a6pRTEH# zjcIbPg4E<-1qFya7GpaVOc^C6?^j6U23e*6w9Jl!VY9ws9+Nx^6VSi_4mP0CGHiZu z*RY5&Sg^?e7425;VG-bAf=Fb6wC4aF6Q-fS17gDjHaBWaVRm8>aAD%$>R@DnsGh{g l!oXkxa-e_{P`rx~LiaFEVPs$fEA4|Rod#99IaFs8F93QmCXoOD delta 281 zcmbPO(q6KmQC@_FiA98i!+?#6g++`-hRuORgw1X8NqJdO1}ri`K$#dZBndsJmLN8! z$rBZ%CWk2~ph_dm>{T#jl$v~4A&rZJLxGJ6Xf2!FW)sCcCOM$pGHd}HU@@puWZ3*z zL|DWaEZAf=A5`vP;THfqGYhIFXL78D0yhT-oWHqKV+ykan}7=w2UiCpSnVW6ke@^t iOxTzNoPfezj1anqaS9^?2UukvMCCM?%FXdQn|J{!awmoW diff --git a/tests/golden/cherry.acx b/tests/golden/cherry.acx index 8c3abbb3c8862f312fff53756a8addaf11462f67..aa4d5961aeec74567bf771a959d75442c352b14e 100644 GIT binary patch delta 288 zcmcZ@c{_5$8BtLt78WrU88!zN5f%mx4g(ejHa8B2$qz*3u*d{~Wn?Goi`j!@^-%SJ zWShin#KAIH%)BCI$|y0JOFWGmWRn8WCOZy>%~j%gO!6#DK%)XU*nnDP*!wpJ&BQpfx(1> ifsIMP2`Jvh2%&ozr!X?GftB{blum;x-Mm1_kP85&lO{+2 delta 284 zcmcZ|c` zVq=adB`curUE`X0zK|FP_IF2ee*>Er0_o26c@L zn;(k^ix`6io6Ke&sU8-70kBK6plWg^uaHyV=HP(yH(!yP!tB5%;KIbg)xii>JBbnG lEfEG2HYNclpl}x>gzjOS!pOh@R@nzpISr Date: Tue, 18 Aug 2026 19:05:21 -0700 Subject: [PATCH 12/12] Let the version say which era this is VersionString has moved exactly once before now: it was born saying 3.0 when the C++ interpreter was, and the number has only ever named eras. This branch earns the next one twice over. By the letter: head 5, length of a list, and orderings on lists all answer differently than they did, and changed answers to old questions are a major no matter how accidental the old answers were. By the spirit: a game compiled against this library requires this interpreter, and "a 4.0 game needs a 4.0 interpreter" is the sentence a major version exists to say. The .acx format version stays at 1, deliberately: the byte layout did not move, and that number describes the layout, not the language. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P458PFzYW18hjwnzjmTAJj --- src/main.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.cc b/src/main.cc index 7385216..c58005c 100644 --- a/src/main.cc +++ b/src/main.cc @@ -40,7 +40,7 @@ namespace archetype { - static constexpr std::string_view VersionString = "3.0"; + static constexpr std::string_view VersionString = "4.0"; class CompilationFailure : public std::runtime_error { public: