/
game.sol
781 lines (688 loc) · 24.5 KB
/
game.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
// SPDX-License-Identifier: UNLICENSED
pragma solidity =0.8.9;
import "./tokenid.sol";
import "./transcript.sol";
import {Map, LocationMaps} from "./mapstructure.sol";
import "./furnishings.sol";
import "./gameid.sol";
/// @dev Invalid* errors are raised for ids can't be mapped to their respective items.
error InvalidPlayer(address player);
error InvalidPlayerIndex(uint8 player);
error InvalidLocationToken(bytes32 token);
error InvalidTranscriptLocation(bytes32 token, uint16 location);
error TranscriptExitLocationInvalid(TEID id);
error TranscriptExitLocationIncorrect(
TEID id,
LocationID expect,
LocationID got
);
error TranscriptPlacementInvalid(bytes32 have, bytes32 stateDerived);
error TranscriptPlacementNotFound(TEID id, bytes32 placement);
error TranscriptPlacementSaltNotFound(TEID id, bytes32 placement);
error TranscriptFurnitureOutcomeKindInvalid(
TEID id,
uint256 furniture,
Furnishings.Kind kind
);
error TranscriptFurnitureOutcomeEffectInvalid(
TEID id,
uint256 furniture,
Furnishings.Effect effect
);
error TranscriptFurnitureOutcomePlayerShouldHaveHalted(TEID id);
error TranscriptFurnitureOutcomePlayerShouldNotHaveHalted(TEID id);
error TranscriptFurnitureShouldHaveHalted(
TEID id,
uint256 furniture,
Furnishings.Kind kind,
Furnishings.Effect effect
);
error TranscriptFurnitureShouldNotHaveHalted(
TEID id,
uint256 furniture,
Furnishings.Kind kind,
Furnishings.Effect effect
);
error TranscriptFurnitureUnknownEffect(
TEID id,
uint256 furniture,
Furnishings.Kind kind,
Furnishings.Effect effect
);
error PlayerAlreadyRegistered(address player);
error PlayerNotRegistered(address player);
// A participant includes the game master and all players.
error NotAParticipant(address participant);
error GameFull();
error GameNotStarted();
error GameInProgress();
error GameComplete();
error ZeroMaxPlayers();
error SenderMustBeMaster();
error FurnitureTokenRequired(uint256 actualID);
error AssociatedArraysLenghtMismatch(uint256 have, uint256 expect);
struct Player {
address addr;
/// LocationID Current location of the player
LocationID loc;
bytes32 startLocation;
bytes sceneblob;
/// player profile data, opaque to the contract
bytes profile;
bool halted;
uint8 lives; // The count of deaths the player can survive, 0 is the default
}
struct GameInitArgs {
string tokenURI;
bytes mapVRFBeta;
uint256 maxPlayers;
}
struct Game {
/// @dev The following state supports ERC 1155 tokenization and general ownership and authority
uint256 id;
/// The creator of the game gets a payout provide the game is completed by
/// at least one player.
address creator;
/// The 'dungeon' master (often the creator) reveals the result of each player move.
address master;
/// @dev the following state controls the overal status of the game
/// maximum number of players
uint maxPlayers;
bool started;
bool completed;
Player[] players;
mapping(address => uint8) iplayers;
// The game transcript locations are tokens. The preimages for which are
// required to check the transcript
bytes32[] locationTokens;
mapping(bytes32 => uint16) tokenLocations;
// placedTokens include:
// * furniture - tokens that can only be placed by the game minter (dungeon
// owner), in a specific room in a specific game. placedTokens will
// correspond to things in the dungeon which may be *used* by a player and
// will have an *effect*. These tokens are H(furnitureID, locationID, salt)
// The game creator can only load the tokens if they have posession of the
// corresponding furniture items and if the locationID's are valid for the
// map
// the following are for future:
// * encounters - npc encounters, monsters. H(npcID, locationID, salt)
// * collectibles - tokens that may be dropped by players or monsters or placed in furniture by the game minter
bytes32[] placedTokens;
mapping(bytes32 => uint256) placements;
// These must be loaded after the game completes in order to successfully
// validate the transcript
mapping(bytes32 => bytes32) placementSalts;
// @note mapVRFBeta commits the game to specific map without revealing
// anything about that map. The game map is generated using the alpha input
// to a VRF as its random seed
bytes mapVRFBeta;
// TODO: commitment for item placements
// The following state is recorded on the game after it is completed in
// order to reconcile the state and apply outcomes to participant accounts.
// participants include both the registered player accounts and the game
// master. The creator will only be involved if it is also the master or if
// we are doing things with royalties.
Map map;
}
struct GameStatus {
uint256 id;
/// @dev The creator of the game gets a payout provide the game is completed
/// by at least one player.
address creator;
/// @dev The 'dungeon' master (often the creator) reveals the result of each
/// player move.
address master;
string uri;
bytes mapVRFBeta;
/// maximum number of players
uint maxPlayers;
uint numRegistered;
bool started;
bool completed;
}
library Games {
using Games for Game;
using Locations for Location;
using LocationMaps for Map;
using Links for Link;
using Transcripts for Transcript;
using Furnishings for Furniture;
/// @dev these are duplicated in the arena contract due to limitations of solidity
event TranscriptPlayerEnteredLocation(
uint256 indexed gameId,
TEID eid,
address indexed player,
LocationID indexed entered,
ExitID enteredVia,
LocationID left,
ExitID leftVia
);
event TranscriptPlayerKilledByTrap(
uint256 indexed gameId,
TEID eid,
address indexed player,
LocationID indexed location,
uint256 furniture
);
event TranscriptPlayerDied(
uint256 indexed gameId,
TEID eid,
address indexed player,
LocationID indexed location,
uint256 furniture
);
event TranscriptPlayerGainedLife(
uint256 indexed gameId,
TEID eid,
address indexed player,
LocationID indexed location,
uint256 furniture
);
// only when player.lives > 0
event TranscriptPlayerLostLife(
uint256 indexed gameId,
TEID eid,
address indexed player,
LocationID indexed location,
uint256 furniture
);
event TranscriptPlayerVictory(
uint256 indexed gameId,
TEID eid,
address indexed player,
LocationID indexed location,
uint256 furniture
);
event TranscriptPlayerUsedFurniture(
uint256 indexed gameId,
TEID eid,
address indexed player,
LocationID indexed location,
uint256 furniture,
Furnishings.Kind kind,
Furnishings.Effect effect
);
/// ---------------------------
/// @dev modifiers
modifier hasStarted(Game storage self) {
if (!self.started) revert GameNotStarted();
_;
}
modifier hasNotStarted(Game storage self) {
if (self.started) revert GameInProgress();
_;
}
modifier hasCompleted(Game storage self) {
if (!self.completed) revert GameInProgress();
_;
}
modifier hasNotCompleted(Game storage self) {
if (self.completed) revert GameComplete();
_;
}
/// ---------------------------
/// @dev state changing methods
function _init(
Game storage self,
uint256 id,
GameInitArgs calldata initArgs,
address _msgSender
) internal {
if (
self.players.length != 0 ||
self.locationTokens.length != 0 ||
self.placedTokens.length != 0
) {
revert IsInitialised();
}
if (initArgs.maxPlayers == 0) {
revert ZeroMaxPlayers();
}
self.id = id;
self.mapVRFBeta = initArgs.mapVRFBeta;
self.maxPlayers = initArgs.maxPlayers;
self.creator = _msgSender;
self.master = _msgSender;
self.players.push();
self.map._init();
// Nope: self.locationTokens.push();
// Nope: self.placedTokens.push();
}
function initialized(Game storage self) internal view returns (bool) {
if (self.players.length == 0) {
return false;
}
return true;
}
function status(
Game storage self
) internal view returns (GameStatus memory) {
GameStatus memory gs;
gs.id = self.id;
gs.mapVRFBeta = self.mapVRFBeta;
gs.creator = self.creator;
gs.master = self.master;
// gs.uri = uri(self.id);
gs.started = self.started;
gs.completed = self.completed;
gs.maxPlayers = self.maxPlayers;
gs.numRegistered = self.players.length - 1;
return gs;
}
/// @dev to enable a re-load (in the event of error during load for example)
/// we need a way to reset the map the locations, furnishings etc, whilst
/// keeping the state the transcript relies on intact.
function reset(Game storage self) internal {
for (uint16 i = 0; i < self.locationTokens.length; i++) {
delete self.tokenLocations[self.locationTokens[i]];
}
delete self.locationTokens;
for (uint16 i = 0; i < self.placedTokens.length; i++) {
delete self.placements[self.placedTokens[i]];
}
self.map._reset();
}
// ----------------------------
// Game progression
//
function start(Game storage self) internal hasNotStarted(self) {
self.started = true;
}
function complete(
Game storage self
) internal hasStarted(self) hasNotCompleted(self) {
self.completed = true;
}
function joinGame(
Game storage self,
address playerAddress,
bytes calldata profile
) internal hasNotCompleted(self) hasNotStarted(self) {
uint8 i = self.iplayers[playerAddress];
if (i != 0) {
revert PlayerAlreadyRegistered(playerAddress);
}
if (self.players.length >= type(uint8).max - 1) {
revert GameFull();
}
// the zero'th player is always invalid. otherwise we would need a + 1 here
if (self.players.length > self.maxPlayers) {
revert GameFull();
}
i = uint8(self.players.length);
self.players.push();
Player storage p = self.players[i];
p.addr = playerAddress;
p.profile = profile;
self.iplayers[playerAddress] = i;
}
function setStartLocation(
Game storage self,
address pa,
bytes32 startLocation,
bytes calldata sceneblob
) internal hasNotCompleted(self) hasNotStarted(self) {
uint8 i = self.iplayers[pa];
if (i == 0) {
revert PlayerNotRegistered(pa);
}
// Can't do this until the locations are loaded
// self.players[i].loc = self.location(startLocation);
Player storage p = self.players[i];
p.startLocation = startLocation;
p.sceneblob = sceneblob;
}
/// @notice commit the placement of furniture to a particular game
// The placement is H(locationid, furnitureid, salt). The transcript
// outcome of using the furniture can not be applied, and hence the game
// validity is voided, unless the pre-image matching this placement has
// been loaded when the transcript is checked.
// This call reveals the presence of the token in the game *before* the
// game starts. This is _desired_, players can see the general
// availability of finish conditions, traps and bonuses but can not
// determine where on the map they are placed.
/// @param self the game
/// @param placement KECCAK256(abi.encode(furnitureid, locationid, salt)). NOTICE
/// that we use abi.encode to avoid hash colisions. each
/// field in the hash is 32 bytes always
/// @param furnitureId furniture nft id, sender must be the owner, must be FURNITURE_TYPE
function placeFurniture(
Game storage self,
bytes32 placement,
uint256 furnitureId
) internal hasNotCompleted(self) hasNotStarted(self) {
requireType(furnitureId, TokenID.FURNITURE_TYPE);
self.placedTokens.push(placement);
self.placements[placement] = furnitureId;
}
function placeFurnitureBatch(
Game storage self,
bytes32[] calldata placement,
uint256[] calldata furnitureId
) public {
if (placement.length != furnitureId.length)
revert AssociatedArraysLenghtMismatch(
placement.length,
furnitureId.length
);
for (uint i = 0; i < placement.length; i++) {
placeFurniture(self, placement[i], furnitureId[i]);
}
}
// ----------------------------
// Game transcript checking
/// @notice reveal the salt for a placement
/// @dev revealing the salt is sufficient to allow the placements to be
/// trivialy brute forced, legitimate game clients will have access to the
/// pre-image, but once the salt is on chain the location ids and furniture
/// ids form a very predictable search space.
/// @param self the game instance
/// @param placement the tokenised furniture placement
/// @param salt the salt component of the placement pre-image
function placementReveal(
Game storage self,
bytes32 placement,
bytes32 salt
) internal hasCompleted(self) {
self.placementSalts[placement] = salt;
}
function load(
Game storage self,
Location[] calldata locations
) internal hasCompleted(self) {
self.map.load(locations);
}
function load(
Game storage self,
Exit[] calldata exits
) internal hasCompleted(self) {
self.map.load(exits);
}
function load(
Game storage self,
Link[] calldata links
) internal hasCompleted(self) {
self.map.load(links);
}
/// @dev reveals the mapping from location tokens to location id's in the
/// fullness of time the tokens will be derived from the block number
/// corresponding to when the game transcript entry was created. So it will
/// be many -> 1. Two players getting the same location token for a single
/// location will mean they entered the location on the same block number.
function load(
Game storage self,
TranscriptLocation[] calldata locations
) internal hasCompleted(self) {
for (uint16 i = 0; i < locations.length; i++) {
TranscriptLocation calldata loc = locations[i];
uint16 iloc = LocationID.unwrap(loc.id);
if (iloc == 0 || iloc >= self.map.locations.length) {
revert InvalidTranscriptLocation(loc.token, iloc);
}
self.locationTokens.push(loc.token);
self.tokenLocations[loc.token] = LocationID.unwrap(loc.id);
}
// resolve the player start locations so that the transcripts will work
for (uint8 i = 1; i < self.players.length; i++) {
self.players[i].loc = self.location(self.players[i].startLocation);
}
}
/// @dev plays through the transcript. reverts if any step is invalid. Note
/// if called with end set to TEID(0) the whole transcript will execute
/// *provided* there is enough GAS to do so. Use the semi openrange [cur,
/// end) for processing transcripts in batches. If you mess that up, the
/// Game will need to be reset befor trying again
/// @param self the current game state
/// @param cur the current possition
/// @param trans the game transcript to evaluate
function playTranscript(
Game storage self,
Transcript storage trans,
Furniture[] storage furniture,
TEID cur,
TEID _end
) internal returns (TEID) {
bool cursorComplete = false;
bool halted = false;
Commitment storage co = trans.entries[0]; // undefined entry
for (
;
!cursorComplete &&
(TEID.unwrap(_end) == 0 ||
TEID.unwrap(cur) != TEID.unwrap(_end));
) {
(cur, co, halted, cursorComplete) = trans.next(cur);
Player storage p = player(self, co.player);
if (p.halted) {
revert Halted(co.player);
}
if (co.kind == Transcripts.MoveKind.ExitUse) {
self.useExit(cur, trans, player(self, co.player));
p.halted = halted;
continue;
}
if (co.kind == Transcripts.MoveKind.FurnitureUse) {
self.useFurniture(
cur,
trans,
player(self, co.player),
halted,
furniture
);
p.halted = halted;
continue;
}
}
return cur;
}
function playTranscript(
Game storage self,
Transcript storage trans,
Furniture[] storage furniture
) internal returns (TEID) {
return self.playTranscript(trans, furniture, cursorStart, TEID.wrap(0));
}
/// @notice Attempt to move through an exit link. If successful, the player
/// location is updated to the location on the other side of the link.
function useExit(
Game storage self,
TEID cur,
Transcript storage trans,
Player storage p
) internal hasCompleted(self) {
ExitUse storage u = trans.exitUse(cur);
ExitUseOutcome storage o = trans.exitUseOutcome(cur);
// lookup the location token from the transcript and see if it
// matches the location id we got by traversing the map exit. If
// the token lookup fails or if the location we get doesn't
// match then the transcript or the map are invalid.
LocationID expected = self.location(o.location);
Location storage loc = self.map.location(p.loc);
ExitID egressVia = loc.exitID(u.side, u.egressIndex);
ExitID ingressVia = self.map.traverse(egressVia);
LocationID from = p.loc;
p.loc = self.map.locationid(ingressVia);
if (LocationID.unwrap(expected) != LocationID.unwrap(p.loc)) {
revert TranscriptExitLocationIncorrect(cur, expected, p.loc);
}
emit TranscriptPlayerEnteredLocation(
self.id,
cur,
p.addr,
p.loc,
egressVia,
from,
ingressVia
);
}
function useFurniture(
Game storage self,
TEID cur,
Transcript storage trans,
Player storage p,
bool halted,
Furniture[] storage furniture
) internal hasCompleted(self) {
FurnitureUse storage u = trans.furnitureUse(cur);
FurnitureUseOutcome storage o = trans.furnitureUseOutcome(cur);
// The host of the map has two oposing interests here:
// a) The host is benifited when penalties (traps) fire.
// b) The host is penalised when bonuses fire.
//
// The use is recorded by the player.
// The outcome is recorded by the host.
// resolve the token and verify its effect
uint256 id = self.placements[u.token];
if (id == 0) revert TranscriptPlacementNotFound(cur, u.token);
requireType(id, TokenID.FURNITURE_TYPE);
bytes32 b = self.placementSalts[u.token];
if (b == 0) revert TranscriptPlacementSaltNotFound(cur, u.token);
b = keccak256(abi.encode(id, p.loc, b));
if (b != u.token) revert TranscriptPlacementInvalid(u.token, b);
Furniture storage f = furniture[nftInstance(id)];
if (f.kind != o.kind)
revert TranscriptFurnitureOutcomeKindInvalid(cur, id, o.kind);
// Check the outcome against the actual furniture
bool effectOk = false;
for (uint i = 0; i < f.effects.length; i++) {
if (f.effects[i] == o.effect) {
effectOk = true;
break;
}
}
if (!effectOk)
revert TranscriptFurnitureOutcomeEffectInvalid(cur, id, o.effect);
emit TranscriptPlayerUsedFurniture(
self.id,
cur,
p.addr,
p.loc,
id,
o.kind,
o.effect
);
if (o.effect == Furnishings.Effect.Victory) {
if (!halted)
revert TranscriptFurnitureShouldHaveHalted(
cur,
id,
o.kind,
o.effect
);
emit TranscriptPlayerVictory(self.id, cur, p.addr, p.loc, id);
return;
}
if (o.effect == Furnishings.Effect.Death) {
if (p.lives > 0) {
p.lives -= 1;
if (halted)
revert TranscriptFurnitureShouldNotHaveHalted(
cur,
id,
o.kind,
o.effect
);
emit TranscriptPlayerLostLife(self.id, cur, p.addr, p.loc, id);
} else {
if (!halted)
revert TranscriptFurnitureShouldHaveHalted(
cur,
id,
o.kind,
o.effect
);
if (o.kind == Furnishings.Kind.Trap)
emit TranscriptPlayerKilledByTrap(
self.id,
cur,
p.addr,
p.loc,
id
);
else emit TranscriptPlayerDied(self.id, cur, p.addr, p.loc, id);
}
return;
}
if (o.effect == Furnishings.Effect.FreeLife) {
p.lives += 1;
emit TranscriptPlayerGainedLife(self.id, cur, p.addr, p.loc, id);
return;
}
revert TranscriptFurnitureUnknownEffect(cur, id, o.kind, o.effect);
}
/// ---------------------------
/// @dev state reading methods
function playerRegistered(
Game storage self,
address p
) internal view returns (bool) {
uint8 i = self.iplayers[p];
if (i == 0) {
return false;
}
if (i >= self.players.length) {
return false;
}
return true;
}
function player(
Game storage self,
address _player
) internal view returns (Player storage) {
if (
self.iplayers[_player] == 0 ||
self.iplayers[_player] >= self.players.length
) {
revert InvalidPlayer(_player);
}
return self.players[self.iplayers[_player]];
}
/// @notice get the number of players currently known to the game (they may not be registered by the host yet)
/// @param self game storage ref
/// @return number of known players
function playerCount(Game storage self) internal view returns (uint8) {
return uint8(self.players.length - 1); // players[0] is invalid
}
/// @notice returns the indext player record from storage
/// @dev we account for the zeroth invalid player slot automatically
/// @param self game storage ref
/// @param _iplayer index of player from the half open range [0 - playerCount())
/// @return player storage reference
function player(
Game storage self,
uint8 _iplayer
) internal view returns (Player storage) {
if (_iplayer >= self.players.length - 1) {
revert InvalidPlayerIndex(_iplayer);
}
return self.players[_iplayer + 1];
}
function location(
Game storage self,
bytes32 tok
) internal view returns (LocationID) {
uint16 i = self.tokenLocations[tok];
if (i == 0 || i >= self.map.locations.length) {
revert InvalidLocationToken(tok);
}
return LocationID.wrap(i);
}
function location(
Game storage self,
LocationID id
) internal view returns (Location storage) {
return self.map.location(id);
}
function exit(
Game storage self,
ExitID id
) internal view returns (Exit storage) {
return self.map.exit(id);
}
function link(
Game storage self,
LinkID id
) internal view returns (Link storage) {
return self.map.link(id);
}
}