@maka/maka-cli 5.383.0 → 5.385.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +89 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +276 -43
- package/bundle/typescript/src/commands/game/sideQuest/commands/dispel.js +15 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +24 -9
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +10 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +8 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +7 -6
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +31 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +7 -3
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +3 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +8 -6
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +87 -18
- package/bundle/typescript/src/commands/game/sideQuest/utilities/astral-move.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-combat.js +102 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/barrier-geometry.js +277 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +32 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-glyphs.js +15 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-rulers.js +58 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +30 -4
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +22 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +51 -11
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +24 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ways-out.js +34 -2
- package/package.json +1 -1
|
@@ -4,7 +4,9 @@ import { Direction } from '../types/shared/direction-enum.js';
|
|
|
4
4
|
import { OPEN_FLOOR, spotOf, visibleActorsIn, exitSpot, seatingIn, theSpot, isPerchSpot } from './spots.js';
|
|
5
5
|
import { placeName } from './log-style.js';
|
|
6
6
|
import { DRONE_COLOUR } from './drone-prose.js';
|
|
7
|
-
import { PLAYER_GLYPH, OTHER_PLAYER_GLYPH, OTHER_PLAYER_COLOR, NEUTRAL_GLYPH, HOSTILE_GLYPH, CREW_GLYPH, DEAD_GLYPH, DOWNED_GLYPH, SPOT_GLYPH, RIGGED_GLYPH, UP_GLYPH, CAMERA_GLYPH, CAMERA_COLOR, CAMERA_DEAD_COLOR, exitGlyph, GLYPH_RANK, } from './map-glyphs.js';
|
|
7
|
+
import { PLAYER_GLYPH, OTHER_PLAYER_GLYPH, OTHER_PLAYER_COLOR, NEUTRAL_GLYPH, HOSTILE_GLYPH, CREW_GLYPH, DEAD_GLYPH, DOWNED_GLYPH, SPOT_GLYPH, RIGGED_GLYPH, UP_GLYPH, CAMERA_GLYPH, CAMERA_COLOR, CAMERA_DEAD_COLOR, BARRIER_GLYPH, BARRIER_COLOR, MANA_BARRIER_COLOR, exitGlyph, GLYPH_RANK, } from './map-glyphs.js';
|
|
8
|
+
import { barriersWire } from './ways-out.js';
|
|
9
|
+
import { xRulerLine, yRulerLabel, yGutterWidth } from './map-rulers.js';
|
|
8
10
|
import { key, levelFootprint, dimsForSize, roomRasterRows, METERS_PER_LEVEL, METERS_PER_CELL, } from './room-grid.js';
|
|
9
11
|
/**
|
|
10
12
|
* WHO THIS PANEL DRAWS: everyone you can see, plus every ARO your AR is
|
|
@@ -276,7 +278,16 @@ function buildRenderMaps(grid, room, viewer) {
|
|
|
276
278
|
if (c)
|
|
277
279
|
cameraAt.set(`${c.x},${c.y}`, { cam, z: c.z });
|
|
278
280
|
}
|
|
279
|
-
|
|
281
|
+
const barrierAt = new Map();
|
|
282
|
+
for (const b of barriersWire(room, viewer)) {
|
|
283
|
+
for (const f of b.faces) {
|
|
284
|
+
if (f.breached)
|
|
285
|
+
continue;
|
|
286
|
+
for (const c of f.cells)
|
|
287
|
+
barrierAt.set(key(c), b.kind);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { cellToSpot, cellToExit, verticalAt, cellTerrain, occupants, corpses, slumpedBody, cameraAt, barrierAt };
|
|
280
291
|
}
|
|
281
292
|
function paint(g) {
|
|
282
293
|
const body = g.inverse ? `{inverse}${g.ch}{/inverse}` : g.ch;
|
|
@@ -484,6 +495,9 @@ function glyphAt(c, maps, viewer, isOtherPlayer, isTableAlly, isHostile) {
|
|
|
484
495
|
const lens = maps.cameraAt.get(`${c.x},${c.y}`);
|
|
485
496
|
if (lens && lens.z >= c.z)
|
|
486
497
|
return cameraGlyph(lens.cam);
|
|
498
|
+
const wall = maps.barrierAt.get(k);
|
|
499
|
+
if (wall)
|
|
500
|
+
return { ch: BARRIER_GLYPH, color: wall === 'mana' ? MANA_BARRIER_COLOR : BARRIER_COLOR, rank: GLYPH_RANK.barrier };
|
|
487
501
|
const terrain = maps.cellTerrain.get(k);
|
|
488
502
|
if (terrain)
|
|
489
503
|
return terrain.blocking ? { ch: '▓', color: 'red', rank: GLYPH_RANK.terrainBlocking } : { ch: '▒', color: 'yellow', rank: GLYPH_RANK.terrainCover };
|
|
@@ -552,7 +566,9 @@ function levelPlansFor(room) {
|
|
|
552
566
|
* glyph used. Returns [] for a spotless room (no grid ever synthesizes
|
|
553
567
|
* -- see Room.ensureGrid).
|
|
554
568
|
*/
|
|
555
|
-
export function renderRoomLayout(room, viewer, isOtherPlayer, isTableAlly, isHostile
|
|
569
|
+
export function renderRoomLayout(room, viewer, isOtherPlayer, isTableAlly, isHostile,
|
|
570
|
+
/** The room's PLANE origin: rulers in plane coordinates (map-rulers.ts). */
|
|
571
|
+
origin) {
|
|
556
572
|
const grid = room.ensureGrid();
|
|
557
573
|
if (!grid)
|
|
558
574
|
return [];
|
|
@@ -569,8 +585,11 @@ export function renderRoomLayout(room, viewer, isOtherPlayer, isTableAlly, isHos
|
|
|
569
585
|
const exists = new Set(footprint.map(key));
|
|
570
586
|
if (levelPlans.length > 1)
|
|
571
587
|
rows.push(`{bold}${lvl.label}{/bold}`);
|
|
588
|
+
const gutter = origin ? yGutterWidth(origin.y + y0, y1 - y0 + 1) + 1 : 0;
|
|
589
|
+
if (origin)
|
|
590
|
+
rows.push(`${' '.repeat(gutter)}${xRulerLine(origin.x + x0, x1 - x0 + 1)}`);
|
|
572
591
|
for (let y = y0; y <= y1; y++) {
|
|
573
|
-
let row = '';
|
|
592
|
+
let row = origin ? `${yRulerLabel(origin.y + y, gutter - 1)} ` : '';
|
|
574
593
|
for (let x = x0; x <= x1; x++) {
|
|
575
594
|
const c = { x, y, z: lvl.z };
|
|
576
595
|
row += exists.has(key(c)) ? cell(glyphAt(c, maps, viewer, isOtherPlayer, isTableAlly, isHostile)) : ' ';
|
|
@@ -589,6 +608,11 @@ export function renderRoomLayout(room, viewer, isOtherPlayer, isTableAlly, isHos
|
|
|
589
608
|
// that reads as a keyword in the description must read as one here.
|
|
590
609
|
legend.push(`{yellow-fg}${SPOT_GLYPH}{/yellow-fg} places: ${places.map(placeName).join(', ')}`);
|
|
591
610
|
}
|
|
611
|
+
const kinds = new Set(maps.barrierAt.values());
|
|
612
|
+
if (kinds.has('physical'))
|
|
613
|
+
legend.push(`{${BARRIER_COLOR}-fg}${BARRIER_GLYPH}{/${BARRIER_COLOR}-fg} force barrier -- nothing physical passes; "attack barrier" to break a face`);
|
|
614
|
+
if (kinds.has('mana'))
|
|
615
|
+
legend.push(`{${MANA_BARRIER_COLOR}-fg}${BARRIER_GLYPH}{/${MANA_BARRIER_COLOR}-fg} mana barrier -- stops the astral and spirits; the living walk through`);
|
|
592
616
|
for (const dir of grid.exitCells.keys()) {
|
|
593
617
|
// Name the spot you actually stand in to leave this way -- read
|
|
594
618
|
// from the same resolver movement uses, not from cell coincidence:
|
|
@@ -716,10 +740,13 @@ export function rightAlign(text, width) {
|
|
|
716
740
|
const pad = Math.max(0, width - visibleWidth(text));
|
|
717
741
|
return `${' '.repeat(pad)}${text}`;
|
|
718
742
|
}
|
|
719
|
-
export function roomMetresPerDot(room, availW, viewH) {
|
|
743
|
+
export function roomMetresPerDot(room, availW, viewH, origin) {
|
|
720
744
|
const grid = room.ensureGrid();
|
|
721
745
|
const dims = grid ? { x: grid.dims.x, y: grid.dims.y } : dimsForSize(room.size);
|
|
722
|
-
|
|
746
|
+
// The rulers come out of the same raster (frameCells), so the scale
|
|
747
|
+
// read-out has to subtract them too or it would claim a finer dot.
|
|
748
|
+
const gutter = origin ? yGutterWidth(origin.y, dims.y) + 1 : 0;
|
|
749
|
+
return frameScale(dims.x, dims.y, availW - gutter, origin ? viewH - 1 : viewH) * METERS_PER_CELL;
|
|
723
750
|
}
|
|
724
751
|
export function frameScale(width, height, availW, viewH) {
|
|
725
752
|
// One column a dot -- see cell(). The /2 this used to carry was the
|
|
@@ -758,15 +785,22 @@ export function frameScale(width, height, availW, viewH) {
|
|
|
758
785
|
* The block is centred inside a fixed `viewH` rows so the box height
|
|
759
786
|
* never jumps between rooms either.
|
|
760
787
|
*/
|
|
761
|
-
function frameCells(cells, availW, viewH
|
|
788
|
+
function frameCells(cells, availW, viewH,
|
|
789
|
+
/** PLANE coordinates of cells[0][0]: draw the x ruler above and the y
|
|
790
|
+
* gutter beside (map-rulers.ts). Both come out of the same raster, so
|
|
791
|
+
* the box never grows -- the room scales a little harder instead. */
|
|
792
|
+
axes) {
|
|
762
793
|
const height = cells.length;
|
|
763
794
|
const width = Math.max(...cells.map(r => r.length), 0);
|
|
764
|
-
const
|
|
795
|
+
const gutter = axes ? yGutterWidth(axes.y, height) + 1 : 0;
|
|
796
|
+
const cols = availW - gutter;
|
|
797
|
+
const rows = axes ? viewH - 1 : viewH;
|
|
798
|
+
const scale = frameScale(width, height, cols, rows);
|
|
765
799
|
const outW = Math.ceil(width / scale);
|
|
766
800
|
const outH = Math.ceil(height / scale);
|
|
767
801
|
const body = [];
|
|
768
802
|
for (let ry = 0; ry < outH; ry++) {
|
|
769
|
-
let line = '';
|
|
803
|
+
let line = axes ? `${yRulerLabel(axes.y + ry * scale, gutter - 1)} ` : '';
|
|
770
804
|
for (let rx = 0; rx < outW; rx++) {
|
|
771
805
|
// The highest-ranked thing in the block wins the dot -- see
|
|
772
806
|
// IGlyph.rank. At scale 1 this is exactly the old behaviour,
|
|
@@ -783,6 +817,8 @@ function frameCells(cells, availW, viewH) {
|
|
|
783
817
|
}
|
|
784
818
|
body.push(line.trimEnd());
|
|
785
819
|
}
|
|
820
|
+
if (axes)
|
|
821
|
+
body.unshift(`${' '.repeat(gutter)}${xRulerLine(axes.x, outW, scale)}`);
|
|
786
822
|
// Vertically centre into a fixed height: a short room shouldn't sit
|
|
787
823
|
// jammed against the top while a tall one fills the box.
|
|
788
824
|
const pad = Math.max(0, viewH - body.length);
|
|
@@ -968,7 +1004,7 @@ export function renderRoomLayoutCompact(room, viewer, opts = {}) {
|
|
|
968
1004
|
// room is SCALED onto it (frameCells), so this bounds the picture
|
|
969
1005
|
// rather than the room: an outdoor room takes its extent from the
|
|
970
1006
|
// district plane and can be far deeper than any size class.
|
|
971
|
-
const { viewH = roomRasterRows(), boxWidth, isOtherPlayer, isTableAlly, isHostile } = opts;
|
|
1007
|
+
const { viewH = roomRasterRows(), boxWidth, isOtherPlayer, isTableAlly, isHostile, origin } = opts;
|
|
972
1008
|
// Width left for the grid once the right-justified elevation bar and
|
|
973
1009
|
// its gap are reserved.
|
|
974
1010
|
const availW = Math.max(6, (boxWidth ?? 27) - ELEVATION_WIDTH - 1);
|
|
@@ -1023,7 +1059,7 @@ export function renderRoomLayoutCompact(room, viewer, opts = {}) {
|
|
|
1023
1059
|
const lenses = new Map();
|
|
1024
1060
|
for (const { cam, z: lz } of maps.cameraAt.values())
|
|
1025
1061
|
lenses.set(lz, [...(lenses.get(lz) ?? []), cam]);
|
|
1026
|
-
return withElevation(frameCells(cells, availW, viewH),
|
|
1062
|
+
return withElevation(frameCells(cells, availW, viewH, origin ? { x: origin.x + x0, y: origin.y + y0 } : undefined),
|
|
1027
1063
|
// Circulating has no level to claim, same as it has no cell.
|
|
1028
1064
|
elevationColumn(grid, viewerCell ? z : undefined, viewH, partialMeters, lenses), boxWidth);
|
|
1029
1065
|
}
|
|
@@ -1099,6 +1135,10 @@ export function roomCaption(room, viewer) {
|
|
|
1099
1135
|
const base = spot ? `at ${spot}` : '(moving through)';
|
|
1100
1136
|
const { above, below } = actorsOnOtherLevels(room, viewer);
|
|
1101
1137
|
const notes = [];
|
|
1138
|
+
// WHERE, IN THE RULERS' NUMBERS -- what "cast barrier x,y" is aimed with.
|
|
1139
|
+
const here = room.scene?.worldCellOf?.(viewer);
|
|
1140
|
+
if (here)
|
|
1141
|
+
notes.push(`${here.x},${here.y},${here.z}`);
|
|
1102
1142
|
if (above > 0)
|
|
1103
1143
|
notes.push(`+${above} above`);
|
|
1104
1144
|
if (below > 0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { droneImmobile } from './drone-prose.js';
|
|
2
2
|
import { Direction } from '../types/shared/direction-enum.js';
|
|
3
|
-
import { isOpenGround, isVoid, roomAtCell, streetDoors } from './district-plane.js';
|
|
3
|
+
import { isOpenGround, isVoid, roomAtCell, streetDoors, worldCell } from './district-plane.js';
|
|
4
4
|
import { isOutdoorsPlace } from './outdoors.js';
|
|
5
5
|
import { wayOut } from './ways-out.js';
|
|
6
6
|
import { significantWords, wordsAreCloseEnough } from './fuzzy-match.js';
|
|
@@ -889,11 +889,24 @@ export function stepFrom(room, mover, direction, count) {
|
|
|
889
889
|
const openOutside = openGroundBeyond(room);
|
|
890
890
|
const furniture = furnitureCells(room);
|
|
891
891
|
const bodies = standingOccupants(room, mover);
|
|
892
|
+
// A BARRIER SPELL is its own word (barrier-geometry.ts): it is drawn,
|
|
893
|
+
// it shimmers, and "the way is blocked" would hide whose working it is.
|
|
894
|
+
// Asked on the plane, so it stops a step off an outdoor room's edge too.
|
|
895
|
+
const scenePlane = room.scene?.spellBarriers?.('physical').length ? room.scene.ensurePlane() : undefined;
|
|
896
|
+
const barrierHere = (local) => {
|
|
897
|
+
if (!scenePlane || !room.scene)
|
|
898
|
+
return undefined;
|
|
899
|
+
const world = worldCell(scenePlane, room.name, local);
|
|
900
|
+
return world ? room.scene.barrierAt(world, 'physical')?.barrier.caster : undefined;
|
|
901
|
+
};
|
|
892
902
|
let cur = start;
|
|
893
903
|
let taken = 0;
|
|
894
904
|
for (let i = 0; i < count; i++) {
|
|
895
905
|
const next = { x: cur.x + step[0], y: cur.y + step[1], z: cur.z };
|
|
896
906
|
const k = key(next);
|
|
907
|
+
const walledBy = barrierHere(next);
|
|
908
|
+
if (walledBy !== undefined)
|
|
909
|
+
return { cell: cur, taken, blockedBy: 'barrier', blocker: walledBy };
|
|
897
910
|
if (!standable.has(k)) {
|
|
898
911
|
const inBounds = next.x >= 0 && next.y >= 0 && next.x < grid.dims.x && next.y < grid.dims.y;
|
|
899
912
|
// Off the edge of an OUTDOOR room is just more street, unless the
|
|
@@ -2526,7 +2539,10 @@ export function describeSpotRoster(room, viewer) {
|
|
|
2526
2539
|
* district, and the room it happens to be over is a label the caller
|
|
2527
2540
|
* re-derives.
|
|
2528
2541
|
*/
|
|
2529
|
-
export function astralStepFrom(plane, from, direction, count, roomAt
|
|
2542
|
+
export function astralStepFrom(plane, from, direction, count, roomAt,
|
|
2543
|
+
/** Whose MANA BARRIER stands on a cell, if any (Scene.barrierAt --
|
|
2544
|
+
* the creator's own weave already excluded, p.315). */
|
|
2545
|
+
manaBarrierAt) {
|
|
2530
2546
|
/**
|
|
2531
2547
|
* UP AND DOWN ARE DIRECTIONS OUT HERE (p.313-314: an astral form
|
|
2532
2548
|
* "can move in any direction" and is "not limited by gravity").
|
|
@@ -2564,6 +2580,12 @@ export function astralStepFrom(plane, from, direction, count, roomAt) {
|
|
|
2564
2580
|
if (here !== there) {
|
|
2565
2581
|
return { cell: cur, taken, blockedBy: 'ward', ward: (there ?? here)?.name };
|
|
2566
2582
|
}
|
|
2583
|
+
// A MANA BARRIER SPELL: "solid, hazily opaque walls that stop astral
|
|
2584
|
+
// movement" (p.315-316). `go` always refused one; a `move` drift
|
|
2585
|
+
// used to float straight through it, because this walk never asked.
|
|
2586
|
+
const weaver = manaBarrierAt?.(next);
|
|
2587
|
+
if (weaver !== undefined)
|
|
2588
|
+
return { cell: cur, taken, blockedBy: 'barrier', ward: weaver };
|
|
2567
2589
|
cur = next;
|
|
2568
2590
|
taken++;
|
|
2569
2591
|
}
|
|
@@ -71,7 +71,7 @@ export function wayOut(room, direction, from) {
|
|
|
71
71
|
* (map-view.ts serializePlane); here a street keeps only what the plane
|
|
72
72
|
* could not place.
|
|
73
73
|
*/
|
|
74
|
-
export function roomGridWire(room) {
|
|
74
|
+
export function roomGridWire(room, viewer) {
|
|
75
75
|
const grid = room.ensureGrid();
|
|
76
76
|
if (!grid)
|
|
77
77
|
return undefined;
|
|
@@ -83,9 +83,41 @@ export function roomGridWire(room) {
|
|
|
83
83
|
damage: cam.damage, structure: cam.structure, destroyed: cam.destroyed,
|
|
84
84
|
}] : [];
|
|
85
85
|
});
|
|
86
|
-
const
|
|
86
|
+
const withCams = cameras.length > 0 ? { ...base, cameras } : base;
|
|
87
|
+
const barriers = barriersWire(room, viewer);
|
|
88
|
+
const wire = barriers.length > 0 ? { ...withCams, barriers } : withCams;
|
|
87
89
|
if (!isOutdoorsPlace(room))
|
|
88
90
|
return wire;
|
|
89
91
|
return { ...wire, doors: wire.doors.filter(d => wayOut(room, d.dir)?.kind === 'grid') };
|
|
90
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* The barrier spells a viewer can see around this room, in its local
|
|
95
|
+
* cells: every standing Physical Barrier with a cell within one of the
|
|
96
|
+
* footprint (its wall ring included), and a Mana Barrier only for its
|
|
97
|
+
* weaver or an eye on the astral (p.294: "invisible to the physical
|
|
98
|
+
* plane"). Breached faces ride along marked, so a client can draw the
|
|
99
|
+
* gap rather than lose the face.
|
|
100
|
+
*/
|
|
101
|
+
export function barriersWire(room, viewer) {
|
|
102
|
+
const scene = room.scene;
|
|
103
|
+
const all = scene?.spellBarriers?.() ?? [];
|
|
104
|
+
if (!scene || all.length === 0)
|
|
105
|
+
return [];
|
|
106
|
+
const frame = scene.ensurePlane().placements.get(roomKey(room.name));
|
|
107
|
+
if (!frame)
|
|
108
|
+
return [];
|
|
109
|
+
const seesAstral = !!viewer && (viewer.plane === 'astral' || viewer.astralPerceiving);
|
|
110
|
+
const near = (c) => c.x >= frame.origin.x - 1 && c.x <= frame.origin.x + frame.dims.x
|
|
111
|
+
&& c.y >= frame.origin.y - 1 && c.y <= frame.origin.y + frame.dims.y;
|
|
112
|
+
return all
|
|
113
|
+
.filter(b => b.kind === 'physical' || seesAstral || b.caster === viewer?.name)
|
|
114
|
+
.filter(b => b.faces.some(f => f.cells.some(near)))
|
|
115
|
+
.map(b => ({
|
|
116
|
+
kind: b.kind, caster: b.caster, shape: b.shape, force: b.force, structure: b.structure,
|
|
117
|
+
faces: b.faces.map(f => ({
|
|
118
|
+
id: f.id, damage: f.damage, breached: f.breached,
|
|
119
|
+
cells: f.cells.map(c => ({ x: c.x - frame.origin.x, y: c.y - frame.origin.y, z: c.z - frame.origin.z })),
|
|
120
|
+
})),
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
91
123
|
//# sourceMappingURL=ways-out.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.385.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
|
|
6
6
|
"description": "A command line tool for scaffolding Meteor 3.x applications using React.",
|