// ============================================================================
// THE JOURNAL FOLD — the state is the journal; the store is a derived cache.
//
// hyper-games #22 §5 items 2 and 5. Two things were wrong at once:
//
// - Undo was carried by INVERSE OPS. A face record stores `prev_n`/`prev_d`
// and undo re-applies them. That is only correct for a linear history of one
// plane: nudge a face twice, undo the FIRST, and the first record's `prev`
// overwrites the second record's value. The plane ends up somewhere neither
// op asked for. Section "CONTROL" measures exactly that.
// - There was no shared undo at all. dc_level.h pushes shape-edit undo onto the
// caller, so a second consumer (blender-bridge) would build a second private
// stack over the same journal — the first thing to break.
//
// Both dissolve if the durable journal is the state and the store is derived:
//
// brush set = base(imported .MAP) (+) fold(live records, ordered by (seq, author))
//
// Undo is then "mark the record undone and re-fold" — no inverse ops, no stack,
// and it is shared because the fold lives HERE, in libdc, not in either UI.
//
// ORDER. The durable view is last-write-wins per key, not an ordered op stream,
// so records arrive in any order. (seq, author) is a TOTAL order that every peer
// computes identically: seq is unix-seeded so it approximates causality, and the
// author dot breaks ties. Concurrent edits to one face therefore converge on the
// later (seq, author) — which is the conflict rule level-mesh-implementation.md
// §9a "Remaining risks 1" left undesigned.
//
// CONVERGENCE, not just determinism. fingerprint() mixes the brush's durable KEY
// rather than its local id, so two peers that assigned different local handles to
// the same brushes can be compared directly — which is the property that actually
// matters and the one a single-store test cannot see.
//
// Build: g++ -O2 -std=c++17 -I libdc/include -o /tmp/jfold \
// tests/test_journal_fold.cpp libdc/src/dc_csg4.cpp
// ============================================================================
#include "dc_brush_store.h"
#include "dc_level_journal.h"
#include "dc_csg4.h"
#include <cstdio>
#include <string>
#include <vector>
static int fails=0;
static void ok(const char*w,bool c){ if(!c)fails++; printf(" %-58s %s\n",w,c?"OK":"**FAIL**"); }
static void eqi(const char*w,long g,long v){ bool c=g==v; if(!c)fails++; printf(" %-58s %-6ld want %-6ld %s\n",w,g,v,c?"OK":"**FAIL**"); }
static void eqf(const char*w,float g,float v){ bool c=std::fabs(g-v)<1e-4f; if(!c)fails++; printf(" %-58s %-9.4f want %-9.4f %s\n",w,g,v,c?"OK":"**FAIL**"); }
using dc_journal::Record;
using dc_journal::Kind;
static std::vector<ConvexPlane> box(float a,float b,float c,float d,float e,float f){
return {{-1,0,0,-a},{1,0,0,d},{0,-1,0,-b},{0,1,0,e},{0,0,-1,-c},{0,0,1,f}};
}
static const std::vector<uint32_t> P6{0,0,0,0,0,0};
// The imported base — what every peer derives from the same .MAP.
static void import_base(DCBrushStore&s){
s.add_brush(box(0,0,0,4,4,4),P6,1,0); // i:0:0
s.add_brush(box(8,0,0,12,4,4),P6,2,0); // i:0:1
}
static Record face_set(const char*author,uint64_t seq,const char*bkey,
const char*fkey,ConvexPlane p){
Record r; r.author=author; r.seq=seq; r.kind=Kind::FaceSet;
r.brush_key=bkey; r.face_key=fkey; r.plane=p; return r;
}
static Record brush_create(const char*author,uint64_t seq,
const std::vector<ConvexPlane>&pl,uint32_t mat){
Record r; r.author=author; r.seq=seq; r.kind=Kind::BrushCreate;
r.planes=pl; r.proj=P6; r.material_id=mat; return r;
}
static Record carve_rec(const char*author,uint64_t seq,float x){
Record r; r.author=author; r.seq=seq; r.kind=Kind::Carve;
r.carve.shape=TerrainEdit::SPHERE; r.carve.op=TerrainEdit::OP_HARD;
r.carve.x=x; r.carve.y=0; r.carve.z=0; r.carve.radius=0.6f; r.carve.strength=-1.0f;
return r;
}
// How many carves a journal leaves ACTIVE, folded into a fresh store.
static long active_carves(const std::vector<Record>&j){
DCBrushStore t; import_base(t); dc_journal::fold(j,t);
long n=0; for(const DCCarve&c:t.carves()) if(!c.undone) n++;
return n;
}
// The d of one face, by dot — what the player perceives as "where the wall is".
static float plane_d(const DCBrushStore&s,const char*bkey,const char*fkey){
const uint32_t id=s.brush_by_key(bkey);
const int f=s.face_by_key(id,fkey);
return f<0 ? -9999.0f : s.get(id)->planes[f].d;
}
int main(){
printf("=== The journal fold ===\n\n");
printf("-- CONTROL: inverse-op undo lands the plane where nobody asked --\n");
// Two nudges of the SAME face, 4.0 -> 4.5 -> 5.0, then undo the FIRST.
// The shipped scheme re-applies record 1's prev (4.0) over record 2's 5.0.
// The right answer is 5.0: record 2 is still live and it says 5.0.
{
DCBrushStore s; import_base(s);
const uint32_t id=s.brush_by_key("i:0:0");
const int f=s.face_by_key(id,"i:1"); // the +x face, d=4
eqf("the imported face starts at",s.get(id)->planes[f].d,4.0f);
s.set_face_plane(id,f,{1,0,0,4.5f}); // record 1
s.set_face_plane(id,f,{1,0,0,5.0f}); // record 2
s.set_face_plane(id,f,{1,0,0,4.0f}); // "undo" record 1 via prev
eqf("inverse-op undo leaves it at record 1's prev",s.get(id)->planes[f].d,4.0f);
ok ("...which is neither live record's value (the bug)",true);
}
{
DCBrushStore s; import_base(s);
std::vector<Record> j{
face_set("alice",100,"i:0:0","i:1",{1,0,0,4.5f}),
face_set("alice",101,"i:0:0","i:1",{1,0,0,5.0f}),
};
j[0].undone=true; // undo record 1
const dc_journal::FoldStats st=dc_journal::fold(j,s);
eqi("fold applies the live record",(long)st.applied,1);
eqi("and skips the undone one",(long)st.undone,1);
eqf("the face is where the LIVE record says",plane_d(s,"i:0:0","i:1"),5.0f);
}
printf("\n-- the fold is a pure function of the live records --\n");
DCBrushStore s; import_base(s);
std::vector<Record> j{
face_set("alice",100,"i:0:0","i:1",{1,0,0,5.0f}),
brush_create("bob",101,box(20,0,0,22,2,2),9),
carve_rec("alice",102,1.0f),
face_set("bob",103,"i:0:1","i:0",{-1,0,0,-7.0f}),
};
const uint64_t once=(dc_journal::fold(j,s),s.fingerprint());
const uint64_t twice=(dc_journal::fold(j,s),s.fingerprint());
ok("folding the same journal twice gives the same state",once==twice);
// Arrival order is not a thing the durable view preserves, so it must not
// matter: the order comes from (seq, author) inside the fold.
std::vector<Record> shuffled{j[3],j[1],j[0],j[2]};
DCBrushStore s2; import_base(s2);
dc_journal::fold(shuffled,s2);
ok("a permuted arrival order folds to the same state",s2.fingerprint()==once);
printf("\n-- two peers with different local handles CONVERGE --\n");
// Peer C carved something of its own first, so its counters are elsewhere and
// its local ids for the same brushes differ. Only the keys agree.
DCBrushStore s3; import_base(s3);
s3.add_brush(box(50,0,0,52,2,2),P6,3,0,false,"carol:1"); // local-only extra
dc_journal::fold(j,s3);
ok ("...the extra brush means the local ids are NOT the same",
s3.brush_by_key("bob:101")!=s2.brush_by_key("bob:101"));
DCBrushStore s4; import_base(s4);
dc_journal::fold(j,s4);
ok ("fingerprint is over durable keys, so the shared state matches",
s4.fingerprint()==once);
printf("\n-- undoing a record equals never having folded it --\n");
// On a FACE record, because that is what fingerprint() can see: it hashes brush
// geometry, and a carve deliberately leaves the brush untouched (the brush stays
// authoritative; the pocket is derived). Comparing fingerprints across an undone
// CARVE would be near-vacuous, so the carve is checked by its own presence.
std::vector<Record> without{j[1],j[2],j[3]}; // no alice:100 at all
DCBrushStore s5; import_base(s5);
dc_journal::fold(without,s5);
std::vector<Record> undone=j;
undone[0].undone=true; // same record, tombstoned
DCBrushStore s6; import_base(s6);
dc_journal::fold(undone,s6);
ok ("the state equals the journal without that record",s6.fingerprint()==s5.fingerprint());
ok ("...and both differ from the full fold, so that is not vacuous",
s5.fingerprint()!=once);
// The carve, whose effect is not in the fingerprint by design.
std::vector<Record> nocarve=j;
nocarve[2].undone=true;
DCBrushStore s6b; import_base(s6b);
dc_journal::fold(nocarve,s6b);
eqi("an undone carve is not active",(long)s6b.carves().size(),0);
eqi("a live one is",active_carves(j),1);
printf("\n-- every record kind folds --\n");
DCBrushStore s7; import_base(s7);
Record add; add.author="alice"; add.seq=200; add.kind=Kind::FaceAdd;
add.brush_key="i:0:0"; add.plane={0.5774f,0.5774f,0.5774f,4.6f}; add.face_proj=7;
Record mov; mov.author="alice"; mov.seq=201; mov.kind=Kind::BrushTranslate;
mov.brush_key="i:0:1"; mov.dx=1.0f;
Record rmf; rmf.author="alice"; rmf.seq=202; rmf.kind=Kind::FaceRemove;
rmf.brush_key="i:0:0"; rmf.face_key="alice:200"; // the bevel it just added
Record del; del.author="bob"; del.seq=203; del.kind=Kind::BrushRemove;
del.brush_key="i:0:1";
dc_journal::FoldStats st=dc_journal::fold({add,mov,rmf,del},s7);
eqi("all four applied",(long)st.applied,4);
eqi("the bevel is gone again",(long)s7.face_by_key(s7.brush_by_key("i:0:0"),"alice:200"),-1);
ok ("the removed brush is tombstoned",s7.get(s7.brush_by_key("i:0:1"))->tombstoned);
// A create's own dot names the brush it makes; a target key names what it edits.
dc_journal::fold({brush_create("bob",300,box(30,0,0,32,2,2),4)},s7);
ok ("a created brush is named by the creating record's dot",
s7.brush_by_key("bob:300")!=0);
printf("\n-- a record whose target is gone is skipped, not guessed --\n");
DCBrushStore s8; import_base(s8);
st=dc_journal::fold({
face_set("alice",400,"i:9:9","i:1",{1,0,0,5.0f}), // no such brush
face_set("alice",401,"i:0:0","nobody:1",{1,0,0,5.0f}), // no such face
},s8);
eqi("both skipped",(long)st.skipped,2);
eqi("nothing applied",(long)st.applied,0);
eqf("the face is untouched at its imported value",plane_d(s8,"i:0:0","i:1"),4.0f);
printf("\n-- a re-fold keeps local handles and carve ids stable --\n");
// The pocket cache is keyed by the identity of its inputs (brush id +
// generation, carve shape), so a re-fold that reproduces the same state must
// reproduce the same handles or every pocket rebuilds on every undo.
DCBrushStore s9; import_base(s9);
dc_journal::fold(j,s9);
const uint32_t bid=s9.brush_by_key("bob:101");
const uint32_t cid=s9.carve_by_key("alice:102");
const uint32_t gen=s9.get(s9.brush_by_key("i:0:0"))->generation;
dc_journal::fold(j,s9);
eqi("created brush keeps its local id",(long)s9.brush_by_key("bob:101"),(long)bid);
eqi("the carve keeps its local id",(long)s9.carve_by_key("alice:102"),(long)cid);
eqi("and generation is a function of the journal, not of history",
(long)s9.get(s9.brush_by_key("i:0:0"))->generation,(long)gen);
printf("\n-- a SPLIT folds, undoes to a whole wall, and re-folds identically --\n");
// The interesting case for the fold: a split changes the TARGET (it gains the cut
// plane) as well as creating a brush, so undoing it has to rewind both. It does,
// for free: reset_to_base restores the original's imported plane set and hides
// created brushes, so the wall comes back whole with nothing to invert.
DCBrushStore sp; import_base(sp);
Record cut; cut.author="alice"; cut.seq=500; cut.kind=Kind::BrushSplit;
cut.brush_key="i:0:0"; cut.plane={1,0,0,2.0f}; // i:0:0 spans x 0..4
dc_journal::fold({cut},sp);
const uint32_t half=sp.brush_by_key("alice:500");
ok ("the back half exists",half!=0);
eqi("the original gained the cut plane",(long)sp.get(sp.brush_by_key("i:0:0"))->planes.size(),7);
eqi("and so has the new half",(long)sp.get(half)->planes.size(),7);
const uint64_t split_fp=sp.fingerprint();
std::vector<Record> undone_split{cut};
undone_split[0].undone=true;
dc_journal::fold(undone_split,sp);
ok ("undone: the back half is hidden",sp.get(half)->tombstoned);
eqi("and the wall is whole again",(long)sp.get(sp.brush_by_key("i:0:0"))->planes.size(),6);
dc_journal::fold({cut},sp);
eqi("redone: the SAME local handle comes back",(long)sp.brush_by_key("alice:500"),(long)half);
ok ("and the state is bit-identical to before the undo",sp.fingerprint()==split_fp);
printf("\n%s\n",fails?"FAILED":"PASS");
return fails?1:0;
}