OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / libdc / include / dc_level_journal.h

261 lines · c ref:
#ifndef DC_LEVEL_JOURNAL_H
#define DC_LEVEL_JOURNAL_H

// ---------------------------------------------------------------------------
// dc_journal — THE JOURNAL IS THE STATE; DCBrushStore IS A DERIVED CACHE.
//
// hyper-games #22 §5 items 2 and 5. Everything a peer can do to a level is a
// RECORD; the level is what you get by folding the live records over the base:
//
//     brush set = base(imported .MAP) (+) fold(live records, by (seq, author))
//
// UNDO IS NOT AN INVERSE OP. It is `undone: true` on a record, plus a re-fold.
// The previous scheme stored the plane it replaced (`prev_n`/`prev_d`) and
// re-applied that, which is only correct for a linear history of ONE plane: nudge
// a face twice, undo the first, and record 1's `prev` overwrites record 2's value
// — the plane lands where neither op asked. A fold cannot have that bug, because
// there is nothing to invert.
//
// AND IT IS SHARED. dc_level.h deliberately pushed shape-edit undo onto the
// caller, which is affordable with one UI and not with two: an in-game interface
// and blender-bridge would each build a private stack over the same journal, which
// is the first thing that breaks. The fold lives HERE, in engine-free libdc, so
// every consumer inherits the same undo rather than reimplementing it.
//
// ORDER — the conflict rule level-mesh-implementation.md §9a left undesigned.
// The durable view is last-write-wins per key, NOT an ordered op stream, so
// records arrive in any order and no arrival order can be trusted. (seq, author)
// is a total order every peer computes identically: seq is unix-seeded so it
// tracks causality loosely, and the author dot breaks ties deterministically. Two
// peers editing one face therefore converge on the later (seq, author).
//
// NAMING, one rule: A RECORD'S OWN DOT NAMES WHATEVER IT CREATES; its payload
// keys name what it TARGETS. So `brush.create` makes a brush called
// "<author>:<seq>", `face.add` gives the new face that same dot, `carve` names the
// carve, and `brush.split` names the back half it cuts off — while `face.set`,
// `face.remove`, `brush.remove` and `brush.translate` carry `brush_key`/`face_key`
// to say what they act on. Nothing needs a separate
// id field, because `world:level:<author>:op:<seq>` already IS the dot.
//
// A record whose target is gone is SKIPPED, never guessed at. A face that was
// removed elsewhere in the journal resolves to nothing, and applying the edit to
// whichever face inherited its index is exactly the silent wrong-geometry failure
// dots exist to prevent.
// ---------------------------------------------------------------------------

#include <algorithm>
#include <cstdint>
#include <string>
#include <vector>

#include "dc_brush_store.h"
#include "dc_edit.h"

namespace dc_journal {

enum class Kind {
    Unknown = 0,
    BrushCreate,      // planes/proj/material_id/level_id/sky; named by the record's dot
    BrushRemove,      // brush_key -> tombstone
    BrushSplit,       // brush_key + plane; the BACK half is named by the record's dot
    BrushTranslate,   // brush_key + dx,dy,dz
    // brush_key + a diagonal scale and the pivot it is about. Its own kind
    // because it is the only edit that changes a brush's SIZE, and because a
    // scale is not expressible as a translation of planes — see
    // DCBrushStore::scale_brush.
    BrushScale,
    FaceSet,          // brush_key + face_key + plane
    FaceAdd,          // brush_key + plane + face_proj; the new face takes the dot
    FaceRemove,       // brush_key + face_key
    Carve,            // carve shape; named by the record's dot
    // TRUE VERTEX / EDGE DRAG. brush_key + the corner POSITIONS being moved + a
    // delta. Its own kind because it is the one edit that re-derives the plane set
    // instead of moving planes — everything else here keeps face identity by
    // construction, and this one has to re-match it. Positions rather than indices
    // because a vertex has no stable index: it is derived, and the derivation order
    // changes the moment the plane set does.
    BrushReshape,
    // THE CSG SET. Each carries its OPERAND, never a brush id — ids come from a local
    // counter, so a record naming one replays against different geometry on another
    // peer. Subtract and intersect carry the operand's PLANE SET outright, which also
    // makes them independent of a cutter brush that the operation itself consumes.
    // Merge is the exception and has to be: it is defined over brushes, and which one
    // SURVIVES is part of the meaning, so it carries durable brush KEYS in order.
    BrushSubtract,    // brush_key + planes (the cutter); pieces named from the dot
    BrushIntersect,   // brush_key + planes (the other solid)
    BrushMerge,       // brush_keys, in order; the first survives
};

struct Record {
    // Identity — the two halves of the durable key world:level:<author>:op:<seq>.
    std::string author;
    uint64_t    seq = 0;
    Kind        kind = Kind::Unknown;
    // A TOMBSTONE VALUE, never an erase. GameState.erase_state has no durable path
    // (it clears the local mirrors and never sends), so an erased record would
    // simply come back on the next room load. Undo therefore has to be a value.
    bool        undone = false;

    // Targets.
    std::string brush_key;
    std::string face_key;

    // Payloads (only the ones this kind needs are read).
    std::vector<ConvexPlane> planes;
    std::vector<uint32_t>    proj;
    uint32_t material_id = 0;
    uint32_t level_id = 0;
    bool     sky = false;
    // Air the level owns rather than geometry (DCBrush::clearance). Journalled
    // like any other property of a create, because a clearance volume has to
    // replicate and undo with the shape it belongs to — a peer that replayed the
    // stairs without their headroom would build a flight buried in hillside.
    bool     clearance = false;
    // The operator invocation this brush belongs to. Journalled with the create
    // because the grouping IS part of what was created — a peer that replayed an
    // arch as nine unrelated brushes would be able to take it apart in ways the
    // author could not.
    std::string group;
    ConvexPlane plane{0.0f, 0.0f, 0.0f, 0.0f};
    uint32_t face_proj = 0;
    float dx = 0.0f, dy = 0.0f, dz = 0.0f;
    // BrushScale: the diagonal factor and the pivot it is taken about. The pivot
    // is carried rather than derived, because "the group's centre" is a function
    // of the whole selection and a replaying peer must scale about the SAME point
    // the author did or the pieces land apart.
    float sx = 1.0f, sy = 1.0f, sz = 1.0f;
    float px = 0.0f, py = 0.0f, pz = 0.0f;
    TerrainEdit carve;
    // BrushReshape: the corners being dragged, in the state the fold has reached by
    // the time this record replays. Sound because the fold is ordered — every record
    // before this one has already been applied, so the geometry these name is the
    // geometry that was there when it was authored.
    std::vector<CSG4Vec3> points;
    // BrushMerge: the brushes being merged, in order, by durable key. The first is
    // the survivor — it keeps its id and its name — so the ORDER is load-bearing and
    // not a set.
    std::vector<std::string> brush_keys;

    std::string dot() const { return author + ":" + std::to_string(seq); }
};

struct FoldStats {
    uint32_t applied = 0;   // records that changed the store
    uint32_t skipped = 0;   // live records whose target is gone / were refused
    uint32_t undone  = 0;   // tombstoned records, deliberately not applied
};

// The total order. seq first (it approximates causality), author to break ties.
inline bool before(const Record& a, const Record& b) {
    if (a.seq != b.seq) return a.seq < b.seq;
    return a.author < b.author;
}

// Apply ONE record. False when it could not be applied — an unknown brush, a face
// that is gone, or a store refusal (an edit that would empty the brush). A refusal
// is information: it means this peer's geometry and the journal disagree.
inline bool apply(const Record& r, DCBrushStore& store) {
    switch (r.kind) {
    case Kind::BrushCreate: {
        // Already known: this is a re-fold, so the brush is sitting tombstoned at
        // its as-created plane set and only needs bringing back. Idempotent either
        // way — replay is not once-only.
        const uint32_t known = store.brush_by_key(r.dot());
        if (known) { store.restore_brush(known); return true; }
        return store.add_brush(r.planes, r.proj, r.material_id, r.level_id,
                               r.sky, r.dot(), r.clearance, r.group) != 0u;
    }
    case Kind::BrushRemove: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        return id && store.remove_brush(id);
    }
    case Kind::BrushSplit: {
        // The target keeps the front half; the back half is a NEW brush named by
        // this record's dot, exactly like a create. split_brush is idempotent and
        // restores both halves after a rewind, so replay needs no special case here.
        const uint32_t id = store.brush_by_key(r.brush_key);
        if (!id) return false;
        return store.split_brush(id, r.plane, r.dot()) != 0u;
    }
    case Kind::BrushScale: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        return id && store.scale_brush(id, r.sx, r.sy, r.sz, r.px, r.py, r.pz);
    }
    case Kind::BrushTranslate: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        return id && store.translate_brush(id, r.dx, r.dy, r.dz);
    }
    case Kind::FaceSet: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        if (!id) return false;
        const int f = store.face_by_key(id, r.face_key);
        if (f < 0) return false;
        return store.set_face_plane(id, (uint32_t)f, r.plane);
    }
    case Kind::FaceAdd: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        if (!id) return false;
        return store.add_face(id, r.plane, r.face_proj, r.dot()) != UINT32_MAX;
    }
    case Kind::FaceRemove: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        if (!id) return false;
        const int f = store.face_by_key(id, r.face_key);
        if (f < 0) return false;
        return store.remove_face(id, (uint32_t)f);
    }
    case Kind::Carve:
        return store.apply_carve(r.carve, r.dot()) != 0u;
    case Kind::BrushSubtract: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        if (!id || r.planes.empty()) return false;
        std::vector<uint32_t> pieces;
        // >= 0 is applied: 0 means the cutter swallowed the brush, which IS the edit.
        // -1 is a refusal — the cutter missed — and that is a skip, not a failure.
        return store.subtract_brush(id, r.planes, r.dot(), pieces) >= 0;
    }
    case Kind::BrushIntersect: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        if (!id || r.planes.empty()) return false;
        return store.intersect_brush(id, r.planes, r.dot());
    }
    case Kind::BrushMerge: {
        std::vector<uint32_t> ids;
        for (const std::string& k : r.brush_keys) {
            const uint32_t id = store.brush_by_key(k);
            if (!id) return false;          // a merged brush is gone: skip, never guess
            ids.push_back(id);
        }
        return store.merge_brushes(ids, r.dot());
    }
    case Kind::BrushReshape: {
        const uint32_t id = store.brush_by_key(r.brush_key);
        if (!id) return false;
        return store.move_brush_points(id, r.points, {r.dx, r.dy, r.dz}, r.dot());
    }
    case Kind::Unknown:
    default:
        return false;
    }
}

// Rewind to the base, then apply every live record in (seq, author) order.
//
// Takes the vector BY VALUE because it sorts it: the caller's arrival order is
// meaningless and must not be mistaken for the fold's order.
inline FoldStats fold(std::vector<Record> records, DCBrushStore& store) {
    std::stable_sort(records.begin(), records.end(), before);
    store.reset_to_base();
    FoldStats st;
    for (const Record& r : records) {
        if (r.undone) { st.undone++; continue; }
        if (apply(r, store)) st.applied++;
        else                 st.skipped++;
    }
    return st;
}

}  // namespace dc_journal

#endif  // DC_LEVEL_JOURNAL_H