I am making a text adventure. The following code is a toy model showing my problem.
public class Player:
private Location location;
private Inventory inventory;
public take(Item item) throws ActionException {
location.remove(item); // throws ActionException if item isn't in location
inventory.add(item); // throws ActionException if item can't be picked up
}
}
My issue is this: what if the item can be removed from the location, but can't be added to the player's inventory? Currently, the code will remove the item from the location but then fail to add it to the player's inventory.
Essentially, I want both to happen, or neither.
Any idea how I can do this? Any help appreciated.
CodePudding user response:
What you're generally looking for is the concept of transactions.
This is non-trivial. The usual strategy is to use DBs, which support it natively.
If you don't want to go there, you can take inspiration from DBs.
They work with versioning schemes. Think blockchain or version control systems like git: You never actually add or remove anything anywhere, instead, you make clones. That way, a reference to some game state can never change, and that's good, because think it through:
Even if the remove works and the add also works, if other threads are involved or there is any code in between these two actions, they can 'witness' the situation where the item is just gone. It has been removed from location
but hasn't been added to inventory
yet.
Imagine a file system. Let's say you have a directory with 2 files. You want to remove the first line from the first file and add that line to the second file. If you actually just do that (edit both files), there will always be a moment-in-time when any other program observing the directory can observe invalid state (where it either doesn't see that line in either file, or sees it in both).
So, instead, you'd do this: You make a new dir, copy both files over, tweak both files, and then rename, in one atomic action, the newly created dir onto its old name. Any program, assuming they 'grab a handle to the directory' (which is how it works on e.g. linux), cannot possibly observe invalid state. They either get the old state (line is in file 1 and not in file 2), or the new state (line is in file 2 and not in file 1).
You can use the same approach to your code, where all state is immutable, all modifications are done via builders (mutable variants) or one step at a time, with immutable trees in between, and once you're done all you do is take a single field of type GameState
and update it to reference the new state - java guarantees that if you write: someObj = someExpr
, that other threads will either see the old state or the new state, they can't see half the pointer or other such nonsense. (You'd still need volatile
or synchronized
or something else to ensure that all threads get the update in a timely fashion).
If threading just doesn't matter, there is one more alternative:
GameState actions.
Instead of just invoking location.remove
, what you can do instead is work with a gamestate action. Such an action knows both how to do the job (remove the item from the location), but it also knows precisely how to undo the job.
You can then write a little framework, where you make a list of gamestate actions (here: the action that can do or undo 'remove from location', and one that can do or undo 'add this to inventory'). You then hand the framework the list of actions. This framework will then go through each action, one at a time, catching exceptions. If it catches one, it will go in reverse order and call undo on every gamestate action.
This way, you have a manageable strategy, where you can just run a bunch of operations in sequence, knowing that if one of them fails, everything done so far will be undone properly.
This strategy is utterly impossible to make work correctly in a multi-threaded environment without global state-locking, so make sure you aren't going to need that in the future.
This is all quite complex. As a consequence... most people would just use a DB engine to do this stuff; they have transactional support. As a bonus, saving your game is now trivial (the DB is saving it for you, all the time).
Note that h2 is a free, open source, all-java (no servers needed, just one jar that needs to be there when your program is run), file-based (As in, all DBs are a single file) DB engine that supports transactions and a decent chunk of SQL syntax. That'd be one way to go. For convenient access, combine it with a nice abstraction over java's core DB access layer, such as JDBI and you've got a system that:
- Can save files trivially.
- Lets you run complex queries in a fast fashion, such as 'find all game rooms with a bleeding monster on it'.
- Fully supports transactions.
You would just run these commands:
START TRANSACTION;
DELETE FROM itemsAtLocation WHERE loc = 18 AND item = 356;
INSERT INTO inventory (itemId) VALUES (356);
COMMIT;
and either both happen or neither happens. As long as you can express rules in terms of DB constraints, the DB will check for you and refuse to commit if you violate a rule. For example, you can trivially state that any given itemId can be in inventory no more than once.
The final and perhaps simplest but least flexible option is to just code it up: All your 'game-state-modifying-code' should FIRST check that it is 100% certain it can perform every task in the sequence in a non-destructive fashion. Only when it knows it is possible, then all jobs are done. If one of them fails halfway through, just hard-crash, your game is now in an unknown, unstable state. The point of throwing exceptions is now relegated to bug-detection: An exception now simply means that you messed up and your check code didn't cover all the bases. Assuming your game has no bugs, the exceptions would never happen. Naturally, this one too just cannot be made to work in a multithreaded fashion. Really, only DBs are a solid answer if that's what you want, or handrolling most of what DBs do.
CodePudding user response:
Ideally, you would have like an inventory.canAddItem(item) function, whatever it may be called, that returns a boolean that you can call before removing from the location. As the commenter pointed out, using Exceptions for control flow is not a great idea.
If it's not an issue to add back to the location, then something like:
public take(Item item) throws ActionException {
location.remove(item);
try{
inventory.add(item);
}
catch(ActionException e){
location.add(item);
}
}
could work for you.