OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / tests / test_journal_fold.cpp

249 lines · cpp ref:
// ============================================================================
// 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;
}