Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 43 additions & 22 deletions core/src/life.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* does that for a day, the pet evolves by itself (but only while awake and not at bedtime).
*
* Which adult form it becomes is decided by the care diary of the child stage (decideSpecies), and
* for the cloud line by the weather the moment it grows up.
* for the cloud and the stone line by the world the moment it grows up (rain, night).
*
* There are several lines of pets. The egg decides which line a pet belongs to: the usual egg is
* a slime, and a rare egg from a trip (home.EGGS) is something else. Until a pet has grown up,
Expand Down Expand Up @@ -57,7 +57,6 @@ export const FORMS = Object.entries(LINES).flatMap(([line, adults]) => [
export const PURRLOOP_LOVE_PER_DAY = 8; // counted pettings per day → purrloop
export const GEMPEROR_CARE = 0.85; // well cared for this part of the time → gemperor, otherwise angloop
export const FLARELET_PLAYS_PER_DAY = 2.5; // the ember line: played with this often → flarelet, otherwise lanternling
export const GLIMMEROCK_LOVE_PER_DAY = 5; // the stone line: petting polishes it → glimmerock, otherwise bouldergrin
export const ROAMTAIL_TRIPS_PER_DAY = 1; // the fox line: a trip a day makes a wanderer → roamtail, otherwise filchfox
// The cloud line is decided by the weather when it grows up: in the rain → rumblefluff, otherwise prismist
export const NEEDLENUB_LOVE_PER_DAY = 5; // the leaf line: a cactus that gets cuddled → needlenub, otherwise twiggle
Expand Down Expand Up @@ -168,38 +167,60 @@ export function record(state: PetState, kind: string, amount = 1): void {
}

/**
* The adult form the care diary and, for the cloud line, the weather lead to. `raining`: whether
* it rains where the pet lives right now.
* The world where the pet lives at the moment it grows up: two lines go by it instead of the care
* diary (the cloud by the rain, the stone by the night).
*/
export function decideSpecies(diary: Record<string, number>, line = "blob", raining = false): string {
export interface World {
/** It rains (or storms) right now. */
raining?: boolean;
/** The sun is down. */
dark?: boolean;
}

/** How many days the child stage of this pet lasts: less what the ember stone took off it. */
export function childDays(state: PetState): number {
return Math.max(1.0, (STAGE_SECONDS.child! - state.egg_warmth) / 86400);
}

/**
* The adult form the care diary and, for the cloud and the stone line, the world lead to.
*
* A rule "so often per day" is a total for the whole child stage (`stageDays`): a day away is made
* up on the others, and while the evolution waits for a tap the goal doesn't grow any more. Until
* then the rule asks for the share of the days so far, so that the hint shows whether the child is
* on its way.
*/
export function decideSpecies(diary: Record<string, number>, line = "blob", world: World = {},
stageDays = STAGE_SECONDS.child! / 86400): string {
const minutes = diary.minutes ?? 0;
const days = Math.max(1.0, minutes / 1440);
const days = Math.min(Math.max(1.0, minutes / 1440), stageDays);
const enough = (kind: string, perDay: number) => (diary[kind] ?? 0) >= perDay * days;
if (line === "ember") {
return (diary.plays ?? 0) / days >= FLARELET_PLAYS_PER_DAY ? "flarelet" : "lanternling";
return enough("plays", FLARELET_PLAYS_PER_DAY) ? "flarelet" : "lanternling";
}
if (line === "stone") {
return (diary.love ?? 0) / days >= GLIMMEROCK_LOVE_PER_DAY ? "glimmerock" : "bouldergrin";
return world.dark ? "glimmerock" : "bouldergrin"; // its crystals light up at night
}
if (line === "fox") {
return (diary.trips ?? 0) / days >= ROAMTAIL_TRIPS_PER_DAY ? "roamtail" : "filchfox";
return enough("trips", ROAMTAIL_TRIPS_PER_DAY) ? "roamtail" : "filchfox";
}
if (line === "cloud") {
return raining ? "rumblefluff" : "prismist";
return world.raining ? "rumblefluff" : "prismist";
}
if (line === "leaf") {
return (diary.love ?? 0) / days >= NEEDLENUB_LOVE_PER_DAY ? "needlenub" : "twiggle";
return enough("love", NEEDLENUB_LOVE_PER_DAY) ? "needlenub" : "twiggle";
}
if (line === "duck") {
return (diary.battles ?? 0) / days >= DRAKWING_BATTLES_PER_DAY ? "drakwing" : "quill";
return enough("battles", DRAKWING_BATTLES_PER_DAY) ? "drakwing" : "quill";
}
if (line === "hippo") {
return (diary.baths ?? 0) / days >= STEAMO_BATHS_PER_DAY ? "steamo" : "lillypot";
return enough("baths", STEAMO_BATHS_PER_DAY) ? "steamo" : "lillypot";
}
if (line === "beak") {
return (diary.tricks ?? 0) / days >= RAPIERRE_TRICKS_PER_DAY ? "rapierre" : "parcelbeak";
return enough("tricks", RAPIERRE_TRICKS_PER_DAY) ? "rapierre" : "parcelbeak";
}
const care = minutes ? (diary.good_minutes ?? 0) / minutes : 1.0;
if ((diary.love ?? 0) / days >= PURRLOOP_LOVE_PER_DAY) {
if (enough("love", PURRLOOP_LOVE_PER_DAY)) {
return "purrloop";
}
return care >= GEMPEROR_CARE ? "gemperor" : "angloop";
Expand All @@ -215,20 +236,20 @@ export const REFORMED: Record<string, string> = {

/**
* The adult form a child is heading for right now (null in its first day and at other stages).
* `raining`: whether it rains right now (the cloud line).
* `world`: the weather and the time of day right now (the cloud and the stone line).
*/
export function hint(state: PetState, raining = false): string | null {
export function hint(state: PetState, world: World = {}): string | null {
if (state.stage !== "child" || (state.diary.minutes ?? 0) < HINT_AFTER) {
return null;
}
return decideSpecies(state.diary, lineOf(state.species), raining);
return decideSpecies(state.diary, lineOf(state.species), world, childDays(state));
}

/**
* Next stage. Returns the old form as "stage:species" (for the animation). `raining`: whether it
* rains right now (the cloud line).
* Next stage. Returns the old form as "stage:species" (for the animation). `world`: the weather
* and the time of day right now (the cloud and the stone line).
*/
export function evolve(state: PetState, now: number, raining = false): string {
export function evolve(state: PetState, now: number, world: World = {}): string {
const old = `${state.stage}:${state.species}`;
const next = NEXT_STAGE[state.stage];
if (next === undefined) {
Expand All @@ -241,7 +262,7 @@ export function evolve(state: PetState, now: number, raining = false): string {
state.diary = newDiary();
}
if (next === "adult") {
state.species = decideSpecies(state.diary, lineOf(state.species), raining);
state.species = decideSpecies(state.diary, lineOf(state.species), world, childDays(state));
}
state.stage = next;
state.stage_since = now;
Expand Down
18 changes: 13 additions & 5 deletions core/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import * as life from "./life";
import { Random } from "./random";
import * as sky from "./sky";
import { STATS, newState, type PetState, type Trip } from "./state";
import type { Location } from "./sun";
import { isDark, type Location } from "./sun";
import * as trip from "./trip";
import * as wild from "./wild";

Expand Down Expand Up @@ -349,7 +349,7 @@ export function tick(state: PetState, t: number, bedtime: Bedtime, weather: sky.
life.recordMinute(state, !state.sick && s.satiety >= CRITICAL && s.hygiene >= CRITICAL && s.mood >= CRITICAL);
const due = life.dueAt(state);
if (due !== null && t >= due + life.AUTO_EVOLVE_AFTER && !state.asleep && !state.trip && !isBedtime(t, bedtime)) {
evolve(state, t, "system", weather);
evolve(state, t, "system", weather, location);
}
}

Expand Down Expand Up @@ -525,9 +525,15 @@ function count(state: PetState, key: string): void {
state.counters[key] = (state.counters[key] ?? 0) + 1;
}

function evolve(state: PetState, now: number, by: string, weather: sky.WeatherCode = null): void {
/** The weather and the time of day at the time t, as far as they decide an adult form. */
export function worldAt(t: number, weather: sky.WeatherCode, location: Location): life.World {
return { raining: sky.precipitation(weather) === "rain", dark: isDark(t, location) };
}

function evolve(state: PetState, now: number, by: string, weather: sky.WeatherCode = null,
location: Location = null): void {
const oldStage = state.stage;
const old = life.evolve(state, now, sky.precipitation(weather) === "rain");
const old = life.evolve(state, now, worldAt(now, weather, location));
care.rescaleWeight(state, oldStage);
state.game = null;
const hatched = old.startsWith("egg");
Expand Down Expand Up @@ -606,6 +612,8 @@ export interface ActionOptions {
weather?: sky.WeatherCode;
/** The temperature in °C, or null without weather data. */
temp?: number | null;
/** Where the sun rises and sets (the night decides a form). */
location?: Location;
/** What else belongs to the action (see checkValue). */
value?: unknown;
}
Expand Down Expand Up @@ -669,7 +677,7 @@ export function applyAction(state: PetState, action: string, now: number, bedtim
} else if (state.trip) {
reason = "away";
} else {
evolve(state, now, by, weather); // gets its own entry in the history
evolve(state, now, by, weather, options.location ?? null); // gets its own entry in the history
return { accepted: true, reason: null };
}
logEvent(state, now, action, by, { accepted: false, reason });
Expand Down
4 changes: 2 additions & 2 deletions core/src/pet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export class Pet {
this.advance(now);
const s = this.state;
return {
species: s.species, stage: s.stage, hint: life.hint(s, sky.precipitation(this.weather()) === "rain"),
species: s.species, stage: s.stage, hint: life.hint(s, model.worldAt(now, this.weather(), this.location)),
asleep: s.asleep, sick: s.sick, poops: s.poops,
activity: model.activityOf(s, now), mood: model.moodOf(s), cold: home.cold(s, now),
needs: model.needs(s), activity_until: s.activity_until,
Expand Down Expand Up @@ -285,7 +285,7 @@ export class Pet {
this.advance(now);
result = model.applyAction(this.state, action, now, this.bedtime(),
{ item: request.item, by, throw: request.throw, weather: this.weather(), temp: this.temperature(),
value: request.value });
location: this.location, value: request.value });
if (id !== null) {
this.requestIds.set(id, result);
while (this.requestIds.size > MAX_REQUEST_IDS) {
Expand Down
8 changes: 8 additions & 0 deletions core/src/sun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,11 @@ export function isDusk(t: number, location: Location = null): boolean {
const minute = date.getHours() * 60 + date.getMinutes();
return Math.abs(minute - sunTimes(date, location)[1]) <= DUSK_MINUTES;
}

/** Is the sun down at the time t (Unix seconds), between sunset and sunrise? */
export function isDark(t: number, location: Location = null): boolean {
const date = new Date(t * 1000);
const minute = date.getHours() * 60 + date.getMinutes();
const [sunrise, sunset] = sunTimes(date, location);
return minute < sunrise || minute >= sunset;
}
37 changes: 29 additions & 8 deletions core/test/life.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,19 @@ test("a rare egg grows up in its own line", () => {
}
});

test("a pebble that is petted a lot grows up polished", () => {
test("a pebble that grows up at night grows up glowing", () => {
const stone = model.newPet(start, "", "stone");
assert.deepEqual(["stone", "glimmerock", "bouldergrin"].map(life.lineOf), ["stone", "stone", "stone"]);
stone.stage = "child";
stone.stage_since = start;
stone.diary = { ...life.newDiary(), minutes: 2880, good_minutes: 2880, plays: 10, love: 8 };
assert.equal(life.hint(stone), "bouldergrin"); // well cared for and played with, but hardly petted
stone.diary.love = 2 * life.GLIMMEROCK_LOVE_PER_DAY;
assert.equal(life.hint(stone), "glimmerock");
assert.equal(act(stone, "evolve", start + 7 * 86400)[0], true);
stone.diary = { ...life.newDiary(), minutes: 2880, good_minutes: 2880, plays: 10, love: 40 };
assert.equal(life.hint(stone), "bouldergrin"); // in daylight, however much it was petted
assert.equal(life.hint(stone, { dark: true }), "glimmerock");
const day = structuredClone(stone);
assert.equal(act(day, "evolve", at("2026-10-19 13:00"))[0], true);
assert.deepEqual([day.stage, day.species], ["adult", "bouldergrin"]);
// after sunset, before bedtime (or woken at night): the crystals come out
assert.equal(act(stone, "evolve", at("2026-10-19 21:00"))[0], true);
assert.deepEqual([stone.stage, stone.species], ["adult", "glimmerock"]);
assert.equal(life.bonusFactor(stone, "pet"), 1.5);
});
Expand All @@ -153,14 +156,32 @@ test("a cloud lamb that grows up in the rain becomes a thundercloud", () => {
cloud.stage_since = start;
cloud.diary = { ...life.newDiary(), minutes: 2880, good_minutes: 2880 };
assert.equal(life.hint(cloud), "prismist"); // the sun is out
assert.equal(life.hint(cloud, true), "rumblefluff"); // it rains right now
assert.equal(life.hint(cloud, { raining: true }), "rumblefluff"); // it rains right now
const sunny = structuredClone(cloud);
assert.equal(act(sunny, "evolve", start + 7 * 86400)[0], true);
assert.deepEqual([sunny.stage, sunny.species], ["adult", "prismist"]);
assert.equal(model.applyAction(cloud, "evolve", start + 7 * 86400, BEDTIME, { weather: 63 }).accepted, true);
assert.deepEqual([cloud.stage, cloud.species], ["adult", "rumblefluff"]);
assert.equal(life.decayFactor(cloud, "mood"), 0.85);
assert.equal(life.decideSpecies({ ...life.newDiary(), minutes: 10080 }, "cloud", true), "rumblefluff");
assert.equal(life.decideSpecies({ ...life.newDiary(), minutes: 10080 }, "cloud", { raining: true }), "rumblefluff");
});

test("a rule per day is a total for the child stage", () => {
const goal = 7 * life.NEEDLENUB_LOVE_PER_DAY;
// a day away: made up on the others
assert.equal(life.decideSpecies({ ...life.newDiary(), minutes: 10080, love: goal }, "leaf"), "needlenub");
// waiting for the tap doesn't raise the goal any more
assert.equal(life.decideSpecies({ ...life.newDiary(), minutes: 10080 + 1440, love: goal }, "leaf"), "needlenub");
// on its way: half the stage, half the goal
assert.equal(life.decideSpecies({ ...life.newDiary(), minutes: 5040, love: goal / 2 }, "leaf"), "needlenub");
assert.equal(life.decideSpecies({ ...life.newDiary(), minutes: 5040, love: goal / 2 - 1 }, "leaf"), "twiggle");
// the ember stone shortens the stage, and the goal with it
const leaf = model.newPet(start, "", "leaf");
leaf.stage = "child";
leaf.diary = { ...life.newDiary(), minutes: 8640, love: 5 * life.NEEDLENUB_LOVE_PER_DAY };
assert.equal(life.hint(leaf), "twiggle");
leaf.egg_warmth = 2 * 86400;
assert.equal(life.hint(leaf), "needlenub");
});

test("the leaf, duck, hippo and beak lines grow up by what the child did most", () => {
Expand Down
4 changes: 3 additions & 1 deletion docs/gameplay.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,9 @@ A grown-up has no use for it.

The care diary of the child stage decides the adult form: how well the pet was
looked after, how often it was played with, petted, bathed and taught tricks,
how often it went on a trip and how many scuffles it had. For one line, the weather at the moment it grows up decides. Each
how often it went on a trip and how many scuffles it had. These count for the
whole child stage, so a day away can be made up on the others. For two lines,
the world at the moment it grows up decides instead. Each
form has a character of its own, with small effects on the game. From its
second day on, the bulb on the child's lure shows by its color which form it
is heading for.
Expand Down
35 changes: 21 additions & 14 deletions docs/pets.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Hearts mean more to it, and it stays clean longer.
<details>
<summary>Show how to get it</summary>

The child was petted at least 8 times a day.
The child was petted 56 times in its week (8 a day).

</details>

Expand All @@ -113,12 +113,15 @@ heading for right now. It changes as soon as the care points somewhere else.
<summary>Show the exact rules</summary>

During the child stage the pet keeps a care diary: how many minutes it was
well cared for, and how often it was played with and petted. When it grows up,
the first rule in this order that matches decides the form:
well cared for, and how often it was played with and petted. A number "a day"
is a total for the whole child stage: a day away is made up on the others, and
with the ember stone (a shorter stage) the total is smaller. Until then the
lure shows whether the child is on its way. When it grows up, the first rule in
this order that matches decides the form:

| Order | Form | Rule |
|---|---|---|
| 1 | Purrloop | petted at least 8 times a day |
| 1 | Purrloop | petted 56 times (8 a day) |
| 2 | Gemperor | well cared for at least 85 % of the time |
| 3 | Angloop | everything else |

Expand Down Expand Up @@ -248,7 +251,7 @@ too):

| Form | Rule |
|---|---|
| Flarelet | the child was played with at least 2.5 times a day on average |
| Flarelet | the child was played with 18 times (2.5 a day) |
| Lanternling | everything else |

From its second day on, the flame on Cindlet's head shows where it is heading:
Expand Down Expand Up @@ -301,19 +304,23 @@ lands back on top.
<details>
<summary>Show how to get them</summary>

Only petting counts for this line: petting polishes the pebble.
For this line, the care diary doesn't count: the night does, the moment the
child grows up. Its crystals want the dark to light up in.

| Form | Rule |
|---|---|
| Glimmerock | the child was petted at least 5 times a day |
| Glimmerock | the sun is down when the child grows up |
| Bouldergrin | everything else |

From its second day on, the quartz band of Pebblet shows where it is heading:
So whoever wants Glimmerock taps **Evolve** after sunset, before bedtime. On a
short summer evening it can be woken at night for it, at the cost of a grumpy
pebble. From its second day on, the quartz band of Pebblet shows what it would
become right now:

| Band | Heading for |
|---|---|
| white | nothing yet (the first day) |
| lilac, like crystal | Glimmerock |
| lilac, like crystal, after sunset | Glimmerock |
| sunny yellow | Bouldergrin |

The values are in `core/src/life.ts`.
Expand Down Expand Up @@ -361,7 +368,7 @@ called straight back from doesn't count.

| Form | Rule |
|---|---|
| Roamtail | the child went on at least 1 trip a day |
| Roamtail | the child went on 7 trips (1 a day) |
| Filchfox | everything else |

From its second day on, the white tip of Kitlet's tail shows where it is
Expand Down Expand Up @@ -473,7 +480,7 @@ Only petting counts for this line: a cactus that gets cuddled.

| Form | Rule |
|---|---|
| Needlenub | the child was petted at least 5 times a day on average |
| Needlenub | the child was petted 35 times (5 a day) |
| Twiggle | everything else |

From its second day on, Sproutlet's leaves show where it is heading:
Expand Down Expand Up @@ -526,7 +533,7 @@ more trips first.

| Form | Rule |
|---|---|
| Drakwing | the child had at least 2 scuffles a day on average |
| Drakwing | the child had 14 scuffles (2 a day) |
| Captain Quill | everything else |

From its second day on, Paddlet's curl shows where it is heading:
Expand Down Expand Up @@ -577,7 +584,7 @@ Only baths count for this line.

| Form | Rule |
|---|---|
| Steamo | the child was bathed at least twice a day on average |
| Steamo | the child was bathed 14 times (twice a day) |
| Lillypot | everything else |

From its second day on, the stripe on Soakling's towel shows where it is
Expand Down Expand Up @@ -631,7 +638,7 @@ already knows doesn't).

| Form | Rule |
|---|---|
| Rapierre | the child practiced tricks at least twice a day on average |
| Rapierre | the child practiced tricks 14 times (twice a day) |
| Parcelbeak | everything else |

From its second day on, Crestling's goggles show where it is heading:
Expand Down
Loading