diff --git a/README.md b/README.md
index ea72285..70f0682 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,8 @@ a day and also tells you the time and the weather.
older pet stays at the back, comes out to play and to teach the young one a
trick, sleeps next to it at night, and swaps places with a tap.
- **It is forgiving.** The pet never dies. Neglect makes it hungry, sad or
- sick, and that can always be fixed.
+ sick, and that can always be fixed. Away for longer, it stays at Grandma's
+ until you fetch it.
- **Quiet at night.** The panel dims or switches off in the hours you choose,
and the pet sleeps.
- **English and German**, and further languages can be added with one file.
diff --git a/app/locales/de.json b/app/locales/de.json
index 4ca2a71..da8c61a 100644
--- a/app/locales/de.json
+++ b/app/locales/de.json
@@ -220,6 +220,7 @@
"no_game": "Gerade läuft kein Spiel.",
"away": "{name} ist unterwegs.",
"not_away": "{name} ist doch daheim.",
+ "at_home": "{name} ist doch hier und nicht bei Oma.",
"too_young": "{name} ist noch zu klein für Ausflüge.",
"sick_trip": "{name} ist krank und bleibt lieber daheim.",
"hungry_trip": "{name} ist zu hungrig für einen Ausflug.",
@@ -259,6 +260,10 @@
"evolve_hold": "Gedrückt halten, bis der Knopf gefüllt ist",
"evolved": "{name} {what}",
"recall": "🏠 Heimrufen",
+ "grandma": {
+ "fetch": "🏡 Bei Oma abholen",
+ "info": "{name} ist bei Oma."
+ },
"unpack": "🎒 Auspacken",
"replay": {
"watch": {
@@ -340,7 +345,7 @@
"save": "Speichern"
},
"welcome": {
- "text": "{name} war {time} allein und hat etwas zu erzählen:",
+ "text": "Nach {time} allein hat {name} etwas zu erzählen:",
"ok": "Schön"
},
"story": {
@@ -397,8 +402,8 @@
"adult": "Ich bin groß geworden – jetzt bin ich ein {species}!"
},
"postcard": {
- "one": "Hallo!\n{name} war einen Tag bei mir, während das Display aus war. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma",
- "other": "Hallo!\n{name} war {n} Tage bei mir, während das Display aus war. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma"
+ "one": "Hallo!\n{name} war einen Tag bei mir. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma",
+ "other": "Hallo!\n{name} war {n} Tage bei mir. {did} Jetzt ist es wieder zu Hause – es hat dich vermisst!\nLiebe Grüße, deine Oma"
},
"grandma": {
"bake": "Wir haben zusammen Kekse gebacken.",
@@ -532,7 +537,8 @@
"pet": "gestreichelt",
"medicine": "Medizin",
"heart": "Herz aufgefangen ❤",
- "away": "war bei Oma (Pi war lange aus)",
+ "away": "war bei Oma",
+ "grandma": "von Oma abgeholt",
"new_egg": "ein neues Ei ist da",
"start": "Links oder rechts? gespielt",
"unpack": "Rucksack ausgepackt",
diff --git a/app/locales/en.json b/app/locales/en.json
index 2365b76..652e9df 100644
--- a/app/locales/en.json
+++ b/app/locales/en.json
@@ -220,6 +220,7 @@
"no_game": "There's no game going on.",
"away": "{name} is out and about.",
"not_away": "{name} is right here at home.",
+ "at_home": "{name} is right here, not at Grandma's.",
"too_young": "{name} is still too little for trips.",
"sick_trip": "{name} is sick and would rather stay home.",
"hungry_trip": "{name} is too hungry for a trip.",
@@ -259,6 +260,10 @@
"evolve_hold": "Keep holding until the button fills up",
"evolved": "{name} {what}",
"recall": "🏠 Call home",
+ "grandma": {
+ "fetch": "🏡 Fetch from Grandma's",
+ "info": "{name} is staying at Grandma's."
+ },
"unpack": "🎒 Unpack",
"replay": {
"watch": {
@@ -397,8 +402,8 @@
"adult": "I've grown up – I'm a {species} now!"
},
"postcard": {
- "one": "Hello!\n{name} stayed with me for a day while the display was off. {did} Now it's back home – it missed you!\nLove, Grandma",
- "other": "Hello!\n{name} stayed with me for {n} days while the display was off. {did} Now it's back home – it missed you!\nLove, Grandma"
+ "one": "Hello!\n{name} stayed with me for a day. {did} Now it's back home – it missed you!\nLove, Grandma",
+ "other": "Hello!\n{name} stayed with me for {n} days. {did} Now it's back home – it missed you!\nLove, Grandma"
},
"grandma": {
"bake": "We baked cookies together.",
@@ -532,7 +537,8 @@
"pet": "petted",
"medicine": "Medicine",
"heart": "caught a heart ❤",
- "away": "stayed at Grandma's (the Pi was off for a long time)",
+ "away": "stayed at Grandma's",
+ "grandma": "fetched from Grandma's",
"new_egg": "a new egg arrived",
"start": "played Left or right?",
"unpack": "unpacked the backpack",
diff --git a/app/pet.html b/app/pet.html
index 618eb6c..b78e8e0 100644
--- a/app/pet.html
+++ b/app/pet.html
@@ -348,6 +348,7 @@
+
@@ -910,6 +911,12 @@
$("trip-info").hidden = false;
$("trip-info").textContent = s.asleep ? t("page.trip.full_asleep") : t("page.trip.back", { name: s.name });
}
+ // at Grandma's (nobody was there for a long time): it waits there to be fetched
+ if (s.grandma) {
+ $("trip-info").hidden = false;
+ $("trip-info").textContent = t("page.grandma.info", { name: s.name });
+ }
+ $("grandma").hidden = !s.grandma;
$("unpack").hidden = !s.backpack;
$("unpack").disabled = !s.actions.unpack;
// the scuffles of the last trip, until they have been watched or the next trip sets off
@@ -949,7 +956,7 @@
// Sleeping: what the button does is what it says
const sleep = $("sleep");
- sleep.hidden = egg || !!s.trip;
+ sleep.hidden = egg || !!s.trip || !!s.grandma;
sleep.innerHTML = s.asleep ? svg("sun") + `${t("page.wake")}` : svg("moon") + `${t("page.sleep")}`;
sleep.disabled = !(s.asleep ? s.actions.wake : s.actions.sleep);
sleep.classList.toggle("wanted", !s.asleep && s.needs.includes("energy"));
@@ -1915,6 +1922,7 @@
before = current;
intoBag(await act("pickup"), "page.picked_bag", "page.picked_fruit");
});
+$("grandma").addEventListener("click", () => act("grandma"));
$("recall").addEventListener("click", async () => {
const r = await act("trip", "recall");
if (r && r.accepted) say(t("page.trip.heading_home", { name: r.state.name }));
diff --git a/core/src/companion.ts b/core/src/companion.ts
index ead38de..00e8c4c 100644
--- a/core/src/companion.ts
+++ b/core/src/companion.ts
@@ -43,7 +43,7 @@ export function outWindow(t: number): [number, number] {
* comes first. Returns "awake", "asleep" (the two sleep side by side) or null.
*/
export function out(front: PetState, t: number, friend: boolean): "awake" | "asleep" | null {
- if (friend || front.trip || front.stage === "egg") {
+ if (friend || front.trip || front.grandma || front.stage === "egg") {
return null;
}
if (front.asleep) {
diff --git a/core/src/model.ts b/core/src/model.ts
index 439ebb4..19052e9 100644
--- a/core/src/model.ts
+++ b/core/src/model.ts
@@ -22,7 +22,16 @@ import * as trip from "./trip";
import * as wild from "./wild";
export const TICK = 60;
-export const MAX_CATCH_UP = 24 * 3600; // off for longer → the pet was "at Grandma's"
+// After a break the simulation catches up at most this far; by then a pet that somebody had
+// looked after has long gone to Grandma's (GRANDMA_AFTER), and one that nobody ever did goes there
+export const MAX_CATCH_UP = 3 * 86400;
+// Nobody has done anything with it for this long: it goes to Grandma's and waits there, frozen,
+// until somebody fetches it (action "grandma"), so that a week away doesn't leave it sick for days.
+// Longer than a day, so that an owner who comes once a day keeps looking after it themselves;
+// shorter than it takes to get sick without care.
+export const GRANDMA_AFTER = 36 * 3600;
+// Grandma looks after it: it comes home with at least this much of every stat
+export const GRANDMA_STATS = 50.0;
export const CRITICAL = 25;
// Change per hour: [awake, asleep]
@@ -48,7 +57,7 @@ const TRAIN_KEYS = battle.SKILLS.map((skill) => `train_${skill}`);
export const EVENT_LIMIT = 100;
export const ACTIONS = ["feed", "play", "sleep", "wake", "clean", "pet", "medicine", "evolve", "game", "trick",
- "trip", "plant", "wear", "deco", "unpack", "train", "collect", "unwrap", "pickup", "look_in"];
+ "trip", "plant", "wear", "deco", "unpack", "train", "collect", "unwrap", "pickup", "look_in", "grandma"];
export const FOODS = ["meal", ...food.EDIBLE];
export const GAME_MOVES = ["start", "left", "right"];
export const TRIP_MOVES = [...trip.AREAS, "recall"];
@@ -64,7 +73,7 @@ export const ACTIVITY_SECONDS: Record = {
egg_wobble: 2, hatch: 6, evolve: 8, game_wait: care.GAME_TIMEOUT, game_turn: 2.5,
trick: 4, build: 10, throw_meal: THROW_SECONDS + 6, throw_snack: THROW_SECONDS + 4,
trip_start: 4, trip_home: 4, unpack: 6, plant: 3, pick: 4, collect: 4, unwrap: 4,
- discover: 4, pickup: 4,
+ discover: 4, pickup: 4, grandma_go: 5, grandma_home: 4,
train_strength: 5, train_speed: 3, train_smarts: 3,
// waiting for the signal, and showing the sequence: as long as the longest one can take
train_ready: battle.SIGNAL_DELAY[1] + battle.SIGNAL_WINDOW,
@@ -235,6 +244,12 @@ export function wakeUp(state: PetState, t: number, temp: number | null = null):
*/
export function tick(state: PetState, t: number, bedtime: Bedtime, weather: sky.WeatherCode = null,
temp: number | null = null, location: Location = null): void {
+ if (!state.grandma && leavesForGrandma(state, t)) {
+ goToGrandma(state, t);
+ }
+ if (state.grandma) {
+ return; // at Grandma's nothing happens to it: no needs, no growing (see stayAtGrandmas)
+ }
const rng = new Random(Math.trunc(t));
const s = state.stats;
const h = TICK / 3600;
@@ -361,30 +376,84 @@ export function advance(state: PetState, now: number, bedtime: Bedtime, weather:
state.updated_at = now;
return true;
}
- if (now - state.updated_at > MAX_CATCH_UP) {
- const days = Math.max(1, Math.round((now - state.updated_at) / 86400));
- for (const k of STATS) {
- state.stats[k] = Math.max(state.stats[k], 50.0);
- }
- state.poops = 0;
- state.next_poop_at = null;
- state.sick = false;
- state.asleep = false;
- state.trip = null;
- state.updated_at = now;
- // item: about how many days it was off, for Grandma's postcard
- logEvent(state, now, "away", "system", { item: String(days) });
- return true;
- }
let changed = false;
- while (state.updated_at + TICK <= now) {
+ const until = Math.min(now, state.updated_at + MAX_CATCH_UP);
+ while (!state.grandma && state.updated_at + TICK <= until) {
state.updated_at += TICK;
tick(state, state.updated_at, bedtime, weather, temp, location);
changed = true;
}
+ if (!state.grandma && now > until) {
+ // caught up as far as it goes, and still nobody there: Grandma takes it in
+ state.trip = null;
+ goToGrandma(state, state.updated_at);
+ }
+ if (state.grandma) {
+ stayAtGrandmas(state, now);
+ return true;
+ }
return changed;
}
+// ---------- At Grandma's ----------
+
+/**
+ * Does it go to Grandma's at the time t? When nobody has done anything with it for GRANDMA_AFTER
+ * (and it hasn't just come back from her), as soon as it is awake and at home.
+ */
+export function leavesForGrandma(state: PetState, t: number): boolean {
+ const since = Math.max(state.seen_at, state.recent.grandma?.at(-1) ?? 0.0);
+ return since > 0 && t - since >= GRANDMA_AFTER && !state.asleep && !state.trip;
+}
+
+/** Grandma takes it in at the time t; she also clears away what was lying around. */
+export function goToGrandma(state: PetState, t: number): void {
+ state.grandma = t;
+ state.game = state.training = null;
+ state.poops = 0;
+ state.next_poop_at = null;
+ setActivity(state, "grandma_go", t);
+}
+
+/**
+ * At Grandma's, time stands still for it, as among the ancestors: it doesn't get older or grow on,
+ * and what it waits for (the next visit of its friend) waits too. The pet at the back goes with it
+ * (Pet.advance).
+ */
+export function stayAtGrandmas(state: PetState, now: number): void {
+ const gap = Math.max(0, now - state.updated_at);
+ state.stage_since += gap;
+ state.born_at += gap;
+ if (state.friend) {
+ state.friend.next += gap;
+ if (state.friend.until !== undefined) {
+ state.friend.until += gap;
+ }
+ }
+ state.updated_at = Math.max(state.updated_at, now);
+}
+
+/**
+ * Fetched from Grandma's: it comes home looked after, and Grandma writes a postcard about it
+ * (story.ts). `seen_at` stays, so the story tells what happened since somebody was last there.
+ */
+export function comeBack(state: PetState, now: number): void {
+ const days = Math.max(1, Math.round((now - state.grandma) / 86400));
+ for (const k of STATS) {
+ state.stats[k] = Math.max(state.stats[k], GRANDMA_STATS);
+ }
+ state.sick = false;
+ state.asleep = false;
+ state.cold_until = 0.0;
+ state.grandma = 0.0;
+ state.recent.grandma = [now];
+ state.idle_next = 0.0;
+ idle.noticed(state);
+ setActivity(state, "grandma_home", now);
+ // item: about how many days it was there, for Grandma's postcard
+ logEvent(state, now, "away", "system", { item: String(days) });
+}
+
// ---------- Derived values ----------
/** Mood derived from the stats (the first match wins). */
@@ -420,7 +489,7 @@ export function activityOf(state: PetState, now: number): string {
/** Stats that need looking after (for the icons and the app). */
export function needs(state: PetState): string[] {
- if (state.stage === "egg") {
+ if (state.stage === "egg" || state.grandma) {
return [];
}
const result: string[] = NEEDS.filter((k) => state.stats[k] < CRITICAL);
@@ -439,6 +508,10 @@ export function availableActions(state: PetState, now: number): Record [action, false]));
+ if (state.grandma) {
+ // at Grandma's it can only be fetched – and things at home can be arranged
+ return { ...nothing, grandma: true, plant: true, wear: true, deco: true, look_in: state.house > 0 };
+ }
if (state.stage === "egg") {
return { ...nothing, pet: true, evolve: ready };
}
@@ -642,7 +715,22 @@ export function applyAction(state: PetState, action: string, now: number, bedtim
throw new RequestError(check[1]);
}
const value = checkValue(action, item, options.value);
- state.seen_at = now; // somebody is there (for idle.welcome)
+ if (action === "grandma") {
+ // fetched from Grandma's; somebody is there, but what happened since they were last is still to be told
+ const reason = state.grandma ? null : "at_home";
+ if (reason === null) {
+ comeBack(state, now);
+ } else {
+ logEvent(state, now, action, by, { accepted: false, reason });
+ }
+ return { accepted: reason === null, reason };
+ }
+ if (state.grandma && !["plant", "wear", "deco", "look_in"].includes(action)) {
+ comeBack(state, now); // an app from before Grandma: anything it does with the pet fetches it
+ }
+ if (!state.grandma) {
+ state.seen_at = now; // somebody is there (for idle.welcome)
+ }
if (action === "look_in") {
// only a look: it doesn't do anything with the pet, so it isn't in the history either
const reason = !state.house ? "no_house" : state.asleep ? "inside" : null;
diff --git a/core/src/notify.ts b/core/src/notify.ts
index fdb8ce8..6151a8b 100644
--- a/core/src/notify.ts
+++ b/core/src/notify.ts
@@ -14,7 +14,7 @@ import * as model from "./model";
import type { PetState } from "./state";
import type { Location } from "./sun";
-export const HORIZON = model.MAX_CATCH_UP; // longer away and it was at Grandma's anyway
+export const HORIZON = 24 * 3600; // a day ahead at most (a little later it is at Grandma's anyway)
// The needs it calls for, in order of priority
export const CALLS = ["health", "satiety", "hygiene", "mood"] as const;
export type Call = (typeof CALLS)[number];
@@ -27,7 +27,7 @@ export interface Reminder {
/** The needs it would call for right now. */
export function calls(state: PetState): Call[] {
- if (state.stage === "egg") {
+ if (state.stage === "egg" || state.grandma) { // at Grandma's it is looked after
return [];
}
return CALLS.filter((need) => need === "health" ? state.sick : state.stats[need] < model.CRITICAL);
diff --git a/core/src/pet.ts b/core/src/pet.ts
index daae8c6..8946725 100644
--- a/core/src/pet.ts
+++ b/core/src/pet.ts
@@ -234,11 +234,11 @@ export class Pet {
activity_item: s.activity_item, ready: life.isDue(s, now),
weight_status: care.weightStatus(s), game: care.gameActive(s, now),
tricks: care.learned(s),
- trip: tripView(s, now), hat: s.hat, ...decoView(s),
+ trip: tripView(s, now), grandma: !!s.grandma, hat: s.hat, ...decoView(s),
look_in: s.house > 0 && now < s.look_in_until && !s.asleep,
backpack: s.backpack.length > 0,
// a friend that is visiting right now, or the pet at the back that is out; and the egg in the nest (its line)
- visitor: s.friend && wild.visiting(s, now) && !s.trip
+ visitor: s.friend && wild.visiting(s, now) && !s.trip && !s.grandma
? { species: s.friend.species, left: s.friend.until! - now, age: now - s.friend.until! + wild.VISIT_SECONDS }
: this.secondView(now),
nest: s.nest ? s.nest.line : null,
@@ -316,6 +316,7 @@ export class Pet {
throw new model.RequestError("invalid_value");
}
this.advance(now);
+ this.fetchHome(now);
const s = this.state;
const successor = model.nextPet(s, now, this.t("pet.default_name"), egg);
this.collect();
@@ -357,6 +358,7 @@ export class Pet {
throw new model.RequestError("no_ancestor");
}
this.advance(now);
+ this.fetchHome(now);
const front = this.state;
const reason = this.swapRefusal();
if (reason) {
@@ -392,6 +394,7 @@ export class Pet {
*/
swap(now = this.clock()) {
this.advance(now);
+ this.fetchHome(now);
const front = this.state;
const back = this.second;
if (!back) {
@@ -542,13 +545,30 @@ export class Pet {
// the two play together every time the pet at the back has been out by day
for (let end = companion.nextOutEnd(s.updated_at); end <= now; end = companion.nextOutEnd(end)) {
model.advance(s, end, this.bedtime(), this.weather(), this.temperature(), this.location);
- companion.rest(back, end);
+ this.restBack(end);
this.playedTogether(end);
}
}
model.advance(s, now, this.bedtime(), this.weather(), this.temperature(), this.location);
if (back) {
- companion.rest(back, now);
+ this.restBack(now);
+ }
+ }
+
+ /** The pet at the back up to `now`; at Grandma's it is there too, and doesn't grow on either. */
+ private restBack(now: number): void {
+ const back = this.second!;
+ const grandma = this.state.grandma;
+ companion.rest(back, grandma ? Math.max(back.updated_at, grandma) : now);
+ if (grandma) {
+ model.stayAtGrandmas(back, now);
+ }
+ }
+
+ /** Fetches the pet at the front from Grandma's, if it is there: before it changes places. */
+ private fetchHome(now: number): void {
+ if (this.state.grandma) {
+ model.comeBack(this.state, now);
}
}
@@ -658,6 +678,8 @@ export class Pet {
kind: s.plant.kind, label: t(`plants.${s.plant.kind}`),
stage: home.plantStage(s), ripe: home.ripe(s), left: s.plant.left,
} : null,
+ // Since when it has been at Grandma's (it waits there to be fetched: action "grandma"), or null
+ grandma: s.grandma ? { since: Math.round(s.grandma) } : null,
// What happened while nobody was there, told as a little story (story.tell), or null
welcome: story.tell(s, now, t),
// The pet at the back (companion.ts): who it is, whether it is out, and why it can't come to the front
diff --git a/core/src/scene.ts b/core/src/scene.ts
index ad67ae5..b52d505 100644
--- a/core/src/scene.ts
+++ b/core/src/scene.ts
@@ -396,6 +396,8 @@ export interface SceneView {
game?: boolean;
tricks?: readonly string[];
trip?: TripView | null;
+ /** It is staying at Grandma's (nobody was there for a long time): the scene shows home without it. */
+ grandma?: boolean;
hat?: string | null;
/** The decorations outside. */
deco?: readonly string[];
@@ -865,6 +867,14 @@ export class PetScene {
activity, pack: false, found: null, foe: null, wild: null, scale: 1, hidden: false,
};
this.play = null;
+ if (view.grandma) {
+ if (activity === "grandma_go" && view.stage !== "egg") {
+ return this.leave(pose, view, t, dt, now, false); // off to Grandma's
+ }
+ this.x = this.targetX = (this.width - SPRITE) / 2; // where it will be if nobody sees it come back
+ pose.hidden = true;
+ return pose;
+ }
if (activity === "hatch") {
return this.hatch(pose, view, t, now);
}
@@ -884,8 +894,8 @@ export class PetScene {
if (activity === "trip_start") {
return this.leave(pose, view, t, dt, now);
}
- if (activity === "trip_home") {
- return this.arrive(pose, view, t, dt);
+ if (activity === "trip_home" || activity === "grandma_home") {
+ return this.arrive(pose, view, t, dt, activity === "trip_home");
}
if (activity === "go_in" && view.house) {
return this.goIn(pose, view, t, dt, now);
@@ -1744,30 +1754,30 @@ export class PetScene {
return pose;
}
- /** The backpack drops in, the pet grabs it and hops out to the right. */
- private leave(pose: Pose, view: SceneView, t: number, dt: number, now: number): Pose {
+ /** The backpack drops in, the pet grabs it and hops out to the right; without one, it just hops off. */
+ private leave(pose: Pose, view: SceneView, t: number, dt: number, now: number, pack = true): Pose {
const e = this.elapsed(view, now);
pose.face = "happy";
- if (e < THROW_FALL + THROW_BOUNCE) {
+ if (pack && e < THROW_FALL + THROW_BOUNCE) {
pose.thrown = ["backpack", Math.round(this.x) + SPRITE - 6, Math.round(dropLift(e))];
} else {
this.facing = 1;
this.x += THROW_SPEED * dt;
this.targetX = this.x;
this.hop(pose, t, 3.5);
- pose.pack = true;
+ pose.pack = pack;
}
return pose;
}
- /** Comes hopping home from the left; it keeps the backpack on until it is unpacked. */
- private arrive(pose: Pose, view: SceneView, t: number, dt: number): Pose {
+ /** Comes hopping home from the left; from a trip, it keeps the backpack on until it is unpacked. */
+ private arrive(pose: Pose, view: SceneView, t: number, dt: number, pack = true): Pose {
if (this.arriveFor !== view.activity_until) {
this.arriveFor = view.activity_until;
this.x = -SPRITE;
}
const home = (this.width - SPRITE) / 2;
- pose.pack = true;
+ pose.pack = pack;
if (this.x < home - 0.5) {
this.facing = 1;
this.x = Math.min(home, this.x + THROW_SPEED * dt);
diff --git a/core/src/state.ts b/core/src/state.ts
index c17a0ae..092cd96 100644
--- a/core/src/state.ts
+++ b/core/src/state.ts
@@ -227,6 +227,8 @@ export interface PetState {
trace: Trace | null;
/** When somebody fed it or played with it, over the last few days (habits.ts). */
habits: number[];
+ /** Since when it has been staying at Grandma's, because nobody was there (model.GRANDMA_AFTER); 0 = at home. */
+ grandma: number;
}
/** A state with every field at its starting value. */
@@ -286,6 +288,7 @@ export function newState(): PetState {
weather_seen: [],
trace: null,
habits: [],
+ grandma: 0.0,
};
}
diff --git a/core/src/story.ts b/core/src/story.ts
index 64d0080..b0827ce 100644
--- a/core/src/story.ts
+++ b/core/src/story.ts
@@ -1,8 +1,8 @@
/**
* What the pet tells when somebody comes back after a while: each day becomes a few sentences in
* its own voice ("It rained in the morning, and I sat under my umbrella. Then I ate a berry."),
- * put together from pieces in the language files (`page.story.*`). When the Pi was off for more
- * than a day, Grandma, who looked after it, writes a postcard instead.
+ * put together from pieces in the language files (`page.story.*`). When it has been at Grandma's
+ * (model.GRANDMA_AFTER), she writes a postcard about it.
*
* Which things are told, and since when, is decided by idle.welcome; this only puts them into
* words. Which variant a sentence takes is seeded from the time, so it reads the same every time.
@@ -45,6 +45,9 @@ interface Piece {
* idle.welcome). `t` gives the texts in the chosen language.
*/
export function tell(state: PetState, now: number, t: Translate): { since: number; pages: Page[] } | null {
+ if (state.grandma) {
+ return null; // it tells once it has been fetched from Grandma's, together with her postcard
+ }
const welcome = idle.welcome(state, now);
if (!welcome) {
return null;
diff --git a/core/test/api-contract.json b/core/test/api-contract.json
index de54e8a..99673fe 100644
--- a/core/test/api-contract.json
+++ b/core/test/api-contract.json
@@ -19,6 +19,7 @@
"evolve",
"feed",
"game",
+ "grandma",
"look_in",
"medicine",
"pet",
@@ -41,6 +42,7 @@
"GET /api/pet actions.evolve": "boolean",
"GET /api/pet actions.feed": "boolean",
"GET /api/pet actions.game": "boolean",
+ "GET /api/pet actions.grandma": "boolean",
"GET /api/pet actions.look_in": "boolean",
"GET /api/pet actions.medicine": "boolean",
"GET /api/pet actions.pet": "boolean",
@@ -81,6 +83,7 @@
"GET /api/pet friend": "null",
"GET /api/pet game": "null",
"GET /api/pet gift": "boolean",
+ "GET /api/pet grandma": "null",
"GET /api/pet guardians.all": "number",
"GET /api/pet guardians.passed": "number",
"GET /api/pet house": "null",
diff --git a/core/test/companion.test.ts b/core/test/companion.test.ts
index c698090..fd53cc3 100644
--- a/core/test/companion.test.ts
+++ b/core/test/companion.test.ts
@@ -64,6 +64,10 @@ test("a child at the back grows up, decently cared for but not specially", () =>
assert.equal(pet.second!.stats.satiety, companion.STAT_FLOOR); // the one at the front looks after it
const due = life.dueAt(pet.second!)!;
const back = pet.second!;
+ for (let t = now + HOUR; t < due - 60; t += 6 * HOUR) {
+ pet.state.seen_at = t; // somebody is there, so the two stay at home
+ pet.update(t);
+ }
pet.update(due - 60);
assert.equal(back.stage, "child");
const care = back.diary.good_minutes / back.diary.minutes;
@@ -75,6 +79,20 @@ test("a child at the back grows up, decently cared for but not specially", () =>
assert.ok(pet.albumList().some((form) => form?.species === "angloop"));
});
+test("at Grandma's, the pet at the back is there too and doesn't grow on either", () => {
+ const now = at("2026-10-12 12:00");
+ const [, pet] = two(now);
+ const back = pet.second!;
+ for (let t = now; t <= now + 5 * 86400; t += HOUR) {
+ pet.update(t);
+ }
+ assert.ok(pet.state.grandma > 0);
+ const [due, minutes] = [life.dueAt(back)!, back.diary.minutes];
+ pet.update(now + 6 * 86400);
+ assert.equal(pet.view(now + 6 * 86400).visitor, null);
+ assert.deepEqual([life.dueAt(back)! - due, back.diary.minutes], [86400, minutes]);
+});
+
test("now and then it comes out, and at night the two sleep side by side", () => {
const day = at("2026-10-12 08:00");
const [start, end] = companion.outWindow(day + 4 * HOUR);
diff --git a/core/test/idle.test.ts b/core/test/idle.test.ts
index 9abb99b..43a5994 100644
--- a/core/test/idle.test.ts
+++ b/core/test/idle.test.ts
@@ -97,6 +97,7 @@ test("now and then it finds something small, one at a time, and keeps it until s
const found = new Set();
for (let day = 0; day < 20; day++) {
const morning = start + day * 86400;
+ pet.seen_at = morning - HOUR; // somebody was there last night
let first: string | null = null;
for (let t = morning; t < morning + 12 * HOUR; t += HOUR) {
model.advance(pet, t, BEDTIME);
diff --git a/core/test/model.test.ts b/core/test/model.test.ts
index d81defc..c0f7e0a 100644
--- a/core/test/model.test.ts
+++ b/core/test/model.test.ts
@@ -6,7 +6,7 @@ import { beforeEach, test } from "node:test";
import * as home from "../src/home";
import * as life from "../src/life";
import * as model from "../src/model";
-import { toDict, type PetState } from "../src/state";
+import { STATS, toDict, type PetState } from "../src/state";
import { BEDTIME, HOUR, act, at, grown, near, refused } from "./helpers";
let start: number;
@@ -60,11 +60,72 @@ test("catch-up equals continuous run", () => {
assert.deepEqual(toDict(pet), toDict(other));
});
-test("long absence is capped", () => {
- pet.stats.satiety = 30;
+test("with the Pi off for a long time, it went to Grandma's as if it had been on", () => {
+ act(pet, "pet", start);
+ const due = life.dueAt(pet)!;
runUntil("2026-10-20 08:00");
- assert.equal(pet.stats.satiety, 50);
- assert.equal(pet.events.at(-1)!.action, "away");
+ const went = start + model.GRANDMA_AFTER;
+ assert.equal(pet.grandma, went);
+ assert.equal(life.dueAt(pet)! - at("2026-10-20 08:00"), due - went); // it didn't grow on from then
+ assert.deepEqual(act(pet, "grandma", at("2026-10-20 08:00")), [true, null]);
+ assert.deepEqual([pet.grandma, pet.stats.satiety, pet.activity], [0, 50, "grandma_home"]);
+ assert.deepEqual([pet.events.at(-1)!.action, pet.events.at(-1)!.item], ["away", "7"]);
+});
+
+test("a pet nobody has ever looked after goes to Grandma's once the simulation has caught up", () => {
+ runUntil("2026-10-20 08:00");
+ assert.equal(pet.grandma, start + model.MAX_CATCH_UP);
+});
+
+/** Runs the simulation hour by hour, as a Pi that keeps running does. */
+function hourly(until: number): void {
+ for (let t = pet.updated_at + HOUR; t < until; t += HOUR) {
+ model.advance(pet, t, BEDTIME);
+ }
+ model.advance(pet, until, BEDTIME);
+}
+
+test("a while after somebody was last there, it goes to Grandma's and waits there, frozen", () => {
+ act(pet, "pet", start);
+ hourly(start + model.GRANDMA_AFTER - HOUR);
+ assert.equal(pet.grandma, 0);
+ hourly(start + model.GRANDMA_AFTER + 60);
+ assert.equal(pet.grandma, start + model.GRANDMA_AFTER);
+ assert.equal(model.activityOf(pet, pet.grandma + 1), "grandma_go");
+ const [stats, due, born, then] = [{ ...pet.stats }, life.dueAt(pet)!, pet.born_at, pet.updated_at];
+ hourly(start + 7 * 86400);
+ assert.deepEqual(pet.stats, stats); // nothing runs low there
+ assert.deepEqual([model.needs(pet), pet.poops, pet.sick], [[], 0, false]);
+ const away = start + 7 * 86400 - then;
+ assert.deepEqual([life.dueAt(pet)! - due, pet.born_at - born], [away, away]); // nor does it grow on or get older
+ const actions = model.availableActions(pet, pet.updated_at);
+ assert.deepEqual(Object.keys(actions).filter((k) => actions[k]), ["plant", "wear", "deco", "grandma"]);
+ // fetched: it comes home looked after, and stays at least as long again
+ assert.deepEqual(act(pet, "grandma", start + 7 * 86400), [true, null]);
+ assert.equal(pet.grandma, 0);
+ assert.ok(STATS.every((k) => pet.stats[k] >= model.GRANDMA_STATS));
+ assert.deepEqual([pet.events.at(-1)!.action, pet.events.at(-1)!.item], ["away", "6"]);
+ assert.equal(pet.seen_at, start); // what happened before it went is still to be told
+ hourly(start + 8 * 86400 - HOUR);
+ assert.equal(pet.grandma, 0);
+ assert.deepEqual(act(pet, "grandma", pet.updated_at), [false, "at_home"]);
+});
+
+test("asleep, it waits for the morning before it goes to Grandma's", () => {
+ act(pet, "pet", at("2026-10-12 13:00")); // a day and a half later it is the middle of the night
+ hourly(at("2026-10-14 06:00"));
+ assert.deepEqual([pet.asleep, pet.grandma], [true, 0]);
+ hourly(at("2026-10-14 12:00"));
+ assert.ok(pet.grandma > at("2026-10-14 06:00"));
+});
+
+test("an app from before Grandma fetches it with whatever it does", () => {
+ act(pet, "pet", start);
+ hourly(start + 3 * 86400);
+ assert.ok(pet.grandma > 0);
+ assert.deepEqual(act(pet, "feed", start + 3 * 86400, { item: "meal" }), [true, null]);
+ assert.equal(pet.grandma, 0);
+ assert.ok(pet.events.some((e) => e.action === "away"));
});
test("clock jump backwards", () => {
@@ -215,6 +276,7 @@ test("the ember stone makes whatever is still growing grow faster", () => {
for (const state of [pet, other]) {
for (let t = state.updated_at; t < until; t += HOUR) {
state.stats.satiety = state.stats.hygiene = state.stats.mood = 80; // looked after all along
+ state.seen_at = t;
model.advance(state, Math.min(until, t + HOUR), BEDTIME);
}
}
diff --git a/core/test/story.test.ts b/core/test/story.test.ts
index 40a241d..99619a6 100644
--- a/core/test/story.test.ts
+++ b/core/test/story.test.ts
@@ -86,13 +86,21 @@ test("each day gets its own paragraph", () => {
]);
});
-test("after a long time with the Pi off, Grandma writes a postcard", () => {
+test("fetched from Grandma's, the story ends with her postcard", () => {
model.logEvent(pet, start + HOUR, "solo", "system", { item: "ball" });
- model.advance(pet, start + 3 * 86400 + HOUR, BEDTIME);
- const [before, card] = pages(start + 3 * 86400 + 2 * HOUR);
- assert.equal(before.postcard, false);
+ const fetched = start + 4 * 86400 + 2 * HOUR;
+ for (let when = start; when <= fetched; when += HOUR) {
+ model.advance(pet, when, BEDTIME); // the Pi keeps running: a day after somebody was there, it goes
+ }
+ assert.equal(pet.grandma, start + model.GRANDMA_AFTER);
+ assert.equal(story.tell(pet, fetched, t), null); // nothing is told before it is home
+ act(pet, "grandma", fetched);
+ const told = pages(fetched);
+ const [before, card] = [told[0], told.at(-1)!];
+ assert.equal(before.postcard, false); // the day before it went
+ assert.equal(told.filter((page) => page.postcard).length, 1);
assert.equal(card.postcard, true);
- assert.match(card.text, /^Hello!\n.* stayed with me for 3 days while the display was off\. .+\nLove, Grandma$/);
+ assert.match(card.text, /^Hello!\n.* stayed with me for 3 days\. .+\nLove, Grandma$/);
assert.deepEqual(warnings, []);
});
diff --git a/core/test/trip.test.ts b/core/test/trip.test.ts
index db49c7f..6e8b310 100644
--- a/core/test/trip.test.ts
+++ b/core/test/trip.test.ts
@@ -196,6 +196,7 @@ test("plant grows and feeds", () => {
// well fed: the ripe fruit stays on the plant
for (let hours = 18; hours < 73; hours += 6) {
pet.stats.satiety = 90;
+ pet.seen_at = start + (hours - 6) * HOUR; // somebody looks after it
runUntil(start + hours * HOUR);
}
assert.equal(home.ripe(pet), true);
diff --git a/docs/gameplay.md b/docs/gameplay.md
index f394272..f6d3b92 100644
--- a/docs/gameplay.md
+++ b/docs/gameplay.md
@@ -40,14 +40,13 @@ it gets bored and loses a little mood, so playing stays worthwhile.
Three meals, two play sessions and one cleanup a day keep the pet happy. A
day alone leaves it with about two thirds of its energy in the evening, enough
to play and go on a trip. With
-no care at all it is hungry after about half a day, grumpy after a day and
-sick after roughly two and a half.
+no care at all it is hungry after about half a day and grumpy after a day.
+After a day and a half Grandma takes it in (see
+[At Grandma's](#at-grandmas)), before it gets sick.
The simulation runs in fixed one-minute steps with seeded randomness. After a
restart the Pi simply catches up, with the same result as if it had never
-been off. If it was off for more than a day, the pet "stayed at Grandma's"
-and comes back with middling stats; Grandma sends a postcard about it (see
-[While nobody is there](#while-nobody-is-there)).
+been off, so a pet nobody looked after meanwhile is at Grandma's.
## Looking after it
@@ -150,7 +149,8 @@ can be seen in the daytime too.
When a stage is over, the pet sparkles and the app offers **Hatch** or
**Evolve**. You hold the button while standing in front of the panel and watch
-it happen. If nobody does that for a day, the pet evolves by itself.
+it happen. If nobody does that for a day, the pet evolves by itself, unless
+it has gone to Grandma's by then (see [At Grandma's](#at-grandmas)).
Growing up can be sped up. A later pet may bring home the **ember stone**, a
decoration that glows from inside. While it is set out, an egg, a baby and a
@@ -564,8 +564,8 @@ day (the last three at most): up to three sentences, the things that matter
most (growing up, a nest, a new way, a friend, a scuffle) before the small
ones (a fruit, the ball), told in the order they happened. Rain or snow while
it was alone opens the day ("It rained in the morning, so I sat under my
-umbrella"). If the Pi was off for more than a day, a postcard from Grandma
-says how long the pet stayed with her and what they did together.
+umbrella"). If it was at Grandma's, her postcard says how long it stayed
+with her and what they did together.
**It calls you.** In the app from the stores, with the pet on the phone, it
calls with a notification when it needs you: once it is really hungry, dirty
@@ -578,6 +578,31 @@ back. The app asks whether the pet may call the first time you feed it.
Connected to a Pi, the app doesn't call: it can't know what happens there in
the meantime, and somebody else may already have seen to it.
+### At Grandma's
+
+When nobody has done anything with the pet for a day and a half (from any
+phone), it goes to Grandma's, so that a week away doesn't leave it hungry and
+sick for days. Somebody who comes once a day keeps looking after it
+themselves. It hops off to the right as soon as it is awake and at home: in
+the morning if it is asleep, after the trip if it is on one. An egg goes too,
+and so does the pet at the back. With the Pi off, or the app closed with the
+pet on the phone, it is the same as if it had been running.
+
+- The panel shows its home without it: the plant, the house and whatever it
+ left lying around stay; the poops are gone, Grandma cleaned up.
+- At Grandma's time stands still for it, as in the ancestors' gallery:
+ nothing runs low, it doesn't grow on and doesn't get older, and a child's
+ care diary waits. So a child doesn't grow up while you are away, and you
+ are there when it does.
+- The app says it is at Grandma's and offers **Fetch from Grandma's**. A tap
+ brings it home: it hops in from the left, looked after (every stat at least
+ 50, not sick), and the app tells what it did before it went, with Grandma's
+ postcard at the end. The plant, the hat and the decorations can be changed
+ while it is away; anything else that would need the pet waits until it is
+ fetched.
+- After that it stays at least a day and a half again, even if nobody does
+ anything with it.
+
## The app
Four tabs along the bottom: the pet (named after it), *Adventure*, *Diary* and
@@ -653,6 +678,7 @@ keep its own pet or connect to a Pi; the README explains how.
| A scuffle watched again in the app | `app/src/replay.ts` |
| Seeds, plant, food, decorations, hats, eggs, warmth (the ember stone, cold mornings), the house and its spots | `core/src/home.ts` |
| What it does by itself, what it leaves behind or finds at home, and what it tells when you come back | `core/src/idle.ts` |
+| Going to Grandma's, staying there frozen, and being fetched | `core/src/model.ts` (`GRANDMA_AFTER`, `comeBack`) |
| The story it tells when you come back, and Grandma's postcard | `core/src/story.ts`, texts under `page.story` in `app/locales/` |
| When it calls for you next, and the app's notification for it | `core/src/notify.ts`, `app/src/notify.ts` |
| Habits: the times it is usually fed or played with, and waiting for them | `core/src/habits.ts` |
diff --git a/docs/pet_preview.png b/docs/pet_preview.png
index 88364b8..01909c3 100644
Binary files a/docs/pet_preview.png and b/docs/pet_preview.png differ
diff --git a/tools/pet_preview.ts b/tools/pet_preview.ts
index 0d08606..fda0764 100644
--- a/tools/pet_preview.ts
+++ b/tools/pet_preview.ts
@@ -157,6 +157,9 @@ const SCENES: Record = {
}, DUSK, null],
trip_home: [{ activity: "trip_home", backpack: true, mood: "happy" }, DAY, null],
unpack: [{ activity: "unpack", activity_item: "seed_berry,cap", mood: "happy" }, DAY, null],
+ grandma: [{ grandma: true, deco: ["windmill"], plant: { kind: "berry", stage: "grown", ripe: false },
+ trace: { kind: "tower", spot: 0.7, look: 0 } }, DAY, null],
+ grandma_home: [{ activity: "grandma_home", mood: "happy", hat: "daisy" }, DAY, null, 0.5],
train_strength: [{ activity: "train_strength", activity_item: "0.80" }, DAY, null],
train_ready: [{ activity: "train_ready", activity_item: "2.00" }, DAY, null],
train_speed: [{ activity: "train_speed", activity_item: "0.90" }, DAY, null],