diff --git a/core/src/life.ts b/core/src/life.ts index 3d505e0..3d52631 100644 --- a/core/src/life.ts +++ b/core/src/life.ts @@ -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, @@ -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 @@ -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, 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, 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"; @@ -215,20 +236,20 @@ export const REFORMED: Record = { /** * 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) { @@ -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; diff --git a/core/src/model.ts b/core/src/model.ts index 439ebb4..3927aa7 100644 --- a/core/src/model.ts +++ b/core/src/model.ts @@ -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"; @@ -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); } } @@ -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"); @@ -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; } @@ -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 }); diff --git a/core/src/pet.ts b/core/src/pet.ts index daae8c6..f91b537 100644 --- a/core/src/pet.ts +++ b/core/src/pet.ts @@ -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, @@ -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) { diff --git a/core/src/sun.ts b/core/src/sun.ts index 469adc4..b4b2e20 100644 --- a/core/src/sun.ts +++ b/core/src/sun.ts @@ -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; +} diff --git a/core/test/life.test.ts b/core/test/life.test.ts index e4a0836..b2c81a1 100644 --- a/core/test/life.test.ts +++ b/core/test/life.test.ts @@ -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); }); @@ -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", () => { diff --git a/docs/gameplay.md b/docs/gameplay.md index f394272..83b0b79 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -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. diff --git a/docs/pets.md b/docs/pets.md index 7b4c358..eed75cd 100644 --- a/docs/pets.md +++ b/docs/pets.md @@ -86,7 +86,7 @@ Hearts mean more to it, and it stays clean longer.
Show how to get it -The child was petted at least 8 times a day. +The child was petted 56 times in its week (8 a day).
@@ -113,12 +113,15 @@ heading for right now. It changes as soon as the care points somewhere else. Show the exact rules 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 | @@ -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: @@ -301,19 +304,23 @@ lands back on top.
Show how to get them -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`. @@ -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 @@ -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: @@ -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: @@ -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 @@ -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: